diff --git a/examples/global-services/.composablerc b/examples/global-services/.composablerc new file mode 100644 index 00000000..7537a1b3 --- /dev/null +++ b/examples/global-services/.composablerc @@ -0,0 +1,6 @@ +{ + "version": 1, + "cli": { + "packageManager": "pnpm" + } +} \ No newline at end of file diff --git a/examples/global-services/.eslintrc.json b/examples/global-services/.eslintrc.json new file mode 100644 index 00000000..d7dcbb98 --- /dev/null +++ b/examples/global-services/.eslintrc.json @@ -0,0 +1,12 @@ +{ + "extends": ["next/core-web-vitals", "prettier"], + "plugins": ["react"], + "parserOptions": { + "ecmaFeatures": { + "jsx": true + } + }, + "rules": { + "react/jsx-curly-brace-presence": "error" + } +} diff --git a/examples/global-services/.gitignore b/examples/global-services/.gitignore new file mode 100644 index 00000000..537bd7aa --- /dev/null +++ b/examples/global-services/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.idea/ + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.* + +# vercel +.vercel + +# typescript +*.tsbuildinfo + +# Being generated by the moltin js-sdk during dev server from server side requests +/localStorage + +test-results \ No newline at end of file diff --git a/examples/global-services/.lintstagedrc.js b/examples/global-services/.lintstagedrc.js new file mode 100644 index 00000000..0fadc0d4 --- /dev/null +++ b/examples/global-services/.lintstagedrc.js @@ -0,0 +1,32 @@ +const path = require("path"); + +/** + * Using next lint with lint-staged requires this setup + * https://nextjs.org/docs/basic-features/eslint#lint-staged + */ + +const buildEslintCommand = (filenames) => + `next lint --fix --file ${filenames + .map((f) => path.relative(process.cwd(), f)) + .join(" --file ")}`; + +/** + * () => "npm run type:check" + * needs to be a function because arguments are getting passed from lint-staged + * when those arguments get through to the "tsc" command that "npm run type:check" + * is calling the args cause "tsc" to ignore the tsconfig.json in our root directory. + * https://github.com/microsoft/TypeScript/issues/27379 + */ +module.exports = { + "*.{js,jsx}": [ + "npm run format:fix", + buildEslintCommand, + "npm run format:check", + ], + "*.{ts,tsx}": [ + "npm run format:fix", + () => "npm run type:check", + buildEslintCommand, + "npm run format:check", + ], +}; diff --git a/examples/global-services/.prettierignore b/examples/global-services/.prettierignore new file mode 100644 index 00000000..b14c3ee4 --- /dev/null +++ b/examples/global-services/.prettierignore @@ -0,0 +1 @@ +**/.next/** \ No newline at end of file diff --git a/examples/global-services/.prettierrc b/examples/global-services/.prettierrc new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/examples/global-services/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/examples/global-services/README.md b/examples/global-services/README.md new file mode 100644 index 00000000..3fc740eb --- /dev/null +++ b/examples/global-services/README.md @@ -0,0 +1,59 @@ +# global-services Elastic Path storefront starter + +This project was generated with [Composable CLI](https://www.npmjs.com/package/composable-cli). + +This storefront accelerates the development of a direct-to-consumer ecommerce experience using Elastic Path's modular products. + +## Tech Stack + +- [Elastic Path](https://www.elasticpath.com/products): A family of composable products for businesses that need to quickly & easily create unique experiences and next-level customer engagements that drive revenue. + +- [Next.js](https://nextjs.org/): a React framework for building static and server-side rendered applications + +- [Tailwind CSS](https://tailwindcss.com/): enabling you to get started with a range of out the box components that are + easy to customize + +- [Headless UI](https://headlessui.com/): completely unstyled, fully accessible UI components, designed to integrate + beautifully with Tailwind CSS. + +- [Radix UI Primitives](https://www.radix-ui.com/primitives): Unstyled, accessible, open source React primitives for high-quality web apps and design systems. + +- [Typescript](https://www.typescriptlang.org/): a typed superset of JavaScript that compiles to plain JavaScript + +## Getting Started + +Run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page will hot reload as you edit the file. + +## Deployment + +Deployment is typical for a Next.js site. We recommend using a provider +like [Netlify](https://www.netlify.com/blog/2020/11/30/how-to-deploy-next.js-sites-to-netlify/) +or [Vercel](https://vercel.com/docs/frameworks/nextjs) to get full Next.js feature support. + +## Current feature set reference + +| **Feature** | **Notes** | +|------------------------------------------|-----------------------------------------------------------------------------------------------| +| PDP | Product Display Pages | +| PLP | Product Listing Pages. | +| EPCC PXM product variations | [Learn more](https://elasticpath.dev/docs/pxm/products/pxm-product-variations/pxm-variations) | +| EPCC PXM bundles | [Learn more](https://elasticpath.dev/docs/pxm/products/pxm-bundles/pxm-bundles) | +| EPCC PXM hierarchy-based navigation menu | Main site nav driven directly from your store's hierarchy and node structure | +| Prebuilt helper components | Some basic building blocks for typical ecommerce store features | +| Checkout | [Learn more](https://elasticpath.dev/docs/commerce-cloud/checkout/checkout-workflow) | +| Cart | [Learn more](https://elasticpath.dev/docs/commerce-cloud/carts/carts) | + diff --git a/examples/global-services/e2e/checkout-flow.spec.ts b/examples/global-services/e2e/checkout-flow.spec.ts new file mode 100644 index 00000000..7e865658 --- /dev/null +++ b/examples/global-services/e2e/checkout-flow.spec.ts @@ -0,0 +1,41 @@ +import { test } from "@playwright/test"; +import { createD2CProductDetailPage } from "./models/d2c-product-detail-page"; +import { client } from "./util/epcc-client"; +import { createD2CCartPage } from "./models/d2c-cart-page"; +import { createD2CCheckoutPage } from "./models/d2c-checkout-page"; + +test.describe("Checkout flow", async () => { + test("should perform product checkout", async ({ page }) => { + const productDetailPage = createD2CProductDetailPage(page, client); + const cartPage = createD2CCartPage(page); + const checkoutPage = createD2CCheckoutPage(page); + + /* Go to simple product page */ + await productDetailPage.gotoSimpleProduct(); + + /* Add the product to cart */ + await productDetailPage.addProductToCart(); + + /* Go to cart page and checkout */ + await cartPage.goto(); + await cartPage.checkoutCart(); + + /* Enter information */ + await checkoutPage.enterInformation({ + "Email Address": { value: "test@tester.com", fieldType: "input" }, + "First Name": { value: "Jim", fieldType: "input" }, + "Last Name": { value: "Brown", fieldType: "input" }, + Address: { value: "Main Street", fieldType: "input" }, + City: { value: "Brownsville", fieldType: "input" }, + Region: { value: "Browns", fieldType: "input" }, + Postcode: { value: "ABC 123", fieldType: "input" }, + Country: { value: "Algeria", fieldType: "combobox" }, + "Phone Number": { value: "01234567891", fieldType: "input" }, + }); + + await checkoutPage.checkout(); + + /* Continue Shopping */ + await checkoutPage.continueShopping(); + }); +}); diff --git a/examples/global-services/e2e/home-page.spec.ts b/examples/global-services/e2e/home-page.spec.ts new file mode 100644 index 00000000..de251ea1 --- /dev/null +++ b/examples/global-services/e2e/home-page.spec.ts @@ -0,0 +1,10 @@ +import { test } from "@playwright/test"; +import { createD2CHomePage } from "./models/d2c-home-page"; +import { skipIfMissingCatalog } from "./util/missing-published-catalog"; + +test.describe("Home Page", async () => { + test("should load home page", async ({ page }) => { + const d2cHomePage = createD2CHomePage(page); + await d2cHomePage.goto(); + }); +}); diff --git a/examples/global-services/e2e/models/d2c-cart-page.ts b/examples/global-services/e2e/models/d2c-cart-page.ts new file mode 100644 index 00000000..34626aca --- /dev/null +++ b/examples/global-services/e2e/models/d2c-cart-page.ts @@ -0,0 +1,23 @@ +import type { Locator, Page } from "@playwright/test"; + +export interface D2CCartPage { + readonly page: Page; + readonly checkoutBtn: Locator; + readonly goto: () => Promise; + readonly checkoutCart: () => Promise; +} + +export function createD2CCartPage(page: Page): D2CCartPage { + const checkoutBtn = page.getByRole("link", { name: "Checkout" }); + + return { + page, + checkoutBtn, + async goto() { + await page.goto(`/cart`); + }, + async checkoutCart() { + await checkoutBtn.click(); + }, + }; +} diff --git a/examples/global-services/e2e/models/d2c-checkout-page.ts b/examples/global-services/e2e/models/d2c-checkout-page.ts new file mode 100644 index 00000000..e1efec42 --- /dev/null +++ b/examples/global-services/e2e/models/d2c-checkout-page.ts @@ -0,0 +1,48 @@ +import type { Locator, Page } from "@playwright/test"; +import { fillAllFormFields, FormInput } from "../util/fill-form-field"; +import { enterPaymentInformation as _enterPaymentInformation } from "../util/enter-payment-information"; + +export interface D2CCheckoutPage { + readonly page: Page; + readonly payNowBtn: Locator; + readonly checkoutBtn: Locator; + readonly goto: () => Promise; + readonly enterInformation: (values: FormInput) => Promise; + readonly checkout: () => Promise; + readonly enterPaymentInformation: (values: FormInput) => Promise; + readonly submitPayment: () => Promise; + readonly continueShopping: () => Promise; +} + +export function createD2CCheckoutPage(page: Page): D2CCheckoutPage { + const payNowBtn = page.getByRole("button", { name: "Pay $" }); + const checkoutBtn = page.getByRole("button", { name: "Pay $" }); + const continueShoppingBtn = page.getByRole("link", { + name: "Continue shopping", + }); + + return { + page, + payNowBtn, + checkoutBtn, + async goto() { + await page.goto(`/cart`); + }, + async enterPaymentInformation(values: FormInput) { + await _enterPaymentInformation(page, values); + }, + async enterInformation(values: FormInput) { + await fillAllFormFields(page, values); + }, + async submitPayment() { + await payNowBtn.click(); + }, + async checkout() { + await checkoutBtn.click(); + }, + async continueShopping() { + await continueShoppingBtn.click(); + await page.waitForURL("/"); + }, + }; +} diff --git a/examples/global-services/e2e/models/d2c-home-page.ts b/examples/global-services/e2e/models/d2c-home-page.ts new file mode 100644 index 00000000..9a847b8b --- /dev/null +++ b/examples/global-services/e2e/models/d2c-home-page.ts @@ -0,0 +1,15 @@ +import type { Page } from "@playwright/test"; + +export interface D2CHomePage { + readonly page: Page; + readonly goto: () => Promise; +} + +export function createD2CHomePage(page: Page): D2CHomePage { + return { + page, + async goto() { + await page.goto("/"); + }, + }; +} diff --git a/examples/global-services/e2e/models/d2c-product-detail-page.ts b/examples/global-services/e2e/models/d2c-product-detail-page.ts new file mode 100644 index 00000000..538ed27e --- /dev/null +++ b/examples/global-services/e2e/models/d2c-product-detail-page.ts @@ -0,0 +1,132 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { + getProductById, + getSimpleProduct, + getVariationsProduct, +} from "../util/resolver-product-from-store"; +import type {ElasticPath, ProductResponse } from "@elasticpath/js-sdk"; +import { getCartId } from "../util/get-cart-id"; +import { getSkuIdFromOptions } from "../../src/lib/product-helper"; + +const host = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL; + +export interface D2CProductDetailPage { + readonly page: Page; + readonly gotoSimpleProduct: () => Promise; + readonly gotoVariationsProduct: () => Promise; + readonly getCartId: () => Promise; + readonly addProductToCart: () => Promise; + readonly gotoProductVariation: () => Promise; +} + +export function createD2CProductDetailPage( + page: Page, + client: ElasticPath, +): D2CProductDetailPage { + let activeProduct: ProductResponse | undefined; + const addToCartBtn = page.getByRole("button", { name: "Add to Cart" }); + + return { + page, + async gotoSimpleProduct() { + activeProduct = await getSimpleProduct(client); + await skipOrGotoProduct( + page, + "Can't run test because there is no simple product published in the store.", + activeProduct, + ); + }, + async gotoVariationsProduct() { + activeProduct = await getVariationsProduct(client); + await skipOrGotoProduct( + page, + "Can't run test because there is no variation product published in the store.", + activeProduct, + ); + }, + async gotoProductVariation() { + expect( + activeProduct, + "Make sure you call one of the gotoVariationsProduct function first before calling gotoProductVariation", + ).toBeDefined(); + expect(activeProduct?.attributes.base_product).toEqual(true); + + const expectedProductId = await selectOptions(activeProduct!, page); + const product = await getProductById(client, expectedProductId); + + expect(product.data?.id).toBeDefined(); + activeProduct = product.data; + + /* Check to make sure the page has navigated to the selected product */ + await expect(page).toHaveURL(`/products/${expectedProductId}`); + }, + getCartId: getCartId(page), + async addProductToCart() { + expect( + activeProduct, + "Make sure you call one of the gotoProduct function first before calling addProductToCart", + ).toBeDefined(); + /* Get the cart id */ + const cartId = await getCartId(page)(); + + /* Add the product to cart */ + await addToCartBtn.click(); + /* Wait for the cart POST request to complete */ + const reqUrl = `https://${host}/v2/carts/${cartId}/items`; + await page.waitForResponse(reqUrl); + + /* Check to make sure the product has been added to cart */ + const result = await client.Cart(cartId).With("items").Get(); + await expect( + activeProduct?.attributes.price, + "Missing price on active product - make sure the product has a price set can't add to cart without one.", + ).toBeDefined(); + await expect( + result.included?.items.find( + (item) => item.product_id === activeProduct!.id, + ), + ).toHaveProperty("product_id", activeProduct!.id); + }, + }; +} + +async function skipOrGotoProduct( + page: Page, + msg: string, + product?: ProductResponse, +) { + if (!product) { + test.skip(!product, msg); + } else { + await page.goto(`/products/${product.id}`); + } +} + +async function selectOptions( + baseProduct: ProductResponse, + page: Page, +): Promise { + /* select one of each variation option */ + const options = baseProduct.meta.variations?.reduce((acc, variation) => { + return [...acc, ...([variation.options?.[0]] ?? [])]; + }, []); + + if (options && baseProduct.meta.variation_matrix) { + for (const option of options) { + await page.click(`text=${option.name}`); + } + + const variationId = getSkuIdFromOptions( + options.map((x) => x.id), + baseProduct.meta.variation_matrix, + ); + + if (!variationId) { + throw new Error("Unable to resolve variation id."); + } + return variationId; + } + + throw Error("Unable to select options they were not defined."); +} diff --git a/examples/global-services/e2e/product-details-page.spec.ts b/examples/global-services/e2e/product-details-page.spec.ts new file mode 100644 index 00000000..b65cad74 --- /dev/null +++ b/examples/global-services/e2e/product-details-page.spec.ts @@ -0,0 +1,29 @@ +import { test } from "@playwright/test"; +import { createD2CProductDetailPage } from "./models/d2c-product-detail-page"; +import { client } from "./util/epcc-client"; +import { skipIfMissingCatalog } from "./util/missing-published-catalog"; + +test.describe("Product Details Page", async () => { + test("should add a simple product to cart", async ({ page }) => { + const productDetailPage = createD2CProductDetailPage(page, client); + + /* Go to base product page */ + await productDetailPage.gotoSimpleProduct(); + + /* Add the product to cart */ + await productDetailPage.addProductToCart(); + }); + + test("should add variation product to cart", async ({ page }) => { + const productDetailPage = createD2CProductDetailPage(page, client); + + /* Go to base product page */ + await productDetailPage.gotoVariationsProduct(); + + /* Select the product variations */ + await productDetailPage.gotoProductVariation(); + + /* Add the product to cart */ + await productDetailPage.addProductToCart(); + }); +}); diff --git a/examples/global-services/e2e/product-list-page.spec.ts b/examples/global-services/e2e/product-list-page.spec.ts new file mode 100644 index 00000000..9d193c82 --- /dev/null +++ b/examples/global-services/e2e/product-list-page.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from "@playwright/test"; +import { gateway } from "@elasticpath/js-sdk"; +import { buildSiteNavigation } from "../src/lib/build-site-navigation"; + +const host = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL; +const client_id = process.env.NEXT_PUBLIC_EPCC_CLIENT_ID; + +const client = gateway({ + client_id, + host, +}); + +test("should be able to use quick view to view full product details", async ({ + page, + isMobile, +}) => { + /* Go to home page */ + await page.goto("/"); + + /* Get the cart id from the cookie */ + const allCookies = await page.context().cookies(); + const cartId = allCookies.find( + (cookie) => cookie.name === "_store_ep_cart", + )?.value; + + const nav = await buildSiteNavigation(client); + + const firstNavItem = nav[0]; + + if (!firstNavItem) { + test.skip( + true, + "No navigation items found can't test product list page flow", + ); + } + + await page.getByRole("button", {name: "Shop Now"}).click(); + + /* Check to make sure the page has navigated to the product list page for Men's / T-Shirts */ + await expect(page).toHaveURL(`/search`); + + await page.locator('[href*="/products/"]').first().click(); + + /* Check to make sure the page has navigated to the product details page for Simple T-Shirt */ + await page.waitForURL(/\/products\//); +}); diff --git a/examples/global-services/e2e/util/enter-payment-information.ts b/examples/global-services/e2e/util/enter-payment-information.ts new file mode 100644 index 00000000..738196b7 --- /dev/null +++ b/examples/global-services/e2e/util/enter-payment-information.ts @@ -0,0 +1,10 @@ +import { Page } from "@playwright/test"; +import { fillAllFormFields, FormInput } from "./fill-form-field"; + +export async function enterPaymentInformation(page: Page, values: FormInput) { + const paymentIframe = await page + .locator('[id="payment-element"]') + .frameLocator("iframe"); + + await fillAllFormFields(paymentIframe, values); +} diff --git a/examples/global-services/e2e/util/epcc-admin-client.ts b/examples/global-services/e2e/util/epcc-admin-client.ts new file mode 100644 index 00000000..5fefa8f9 --- /dev/null +++ b/examples/global-services/e2e/util/epcc-admin-client.ts @@ -0,0 +1,14 @@ +import { gateway, MemoryStorageFactory } from "@elasticpath/js-sdk"; + +const host = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL; +const client_id = process.env.NEXT_PUBLIC_EPCC_CLIENT_ID; +const client_secret = process.env.EPCC_CLIENT_SECRET; + +export const adminClient = gateway({ + client_id, + client_secret, + host, + throttleEnabled: true, + name: "admin_client", + storage: new MemoryStorageFactory(), +}); diff --git a/examples/global-services/e2e/util/epcc-client.ts b/examples/global-services/e2e/util/epcc-client.ts new file mode 100644 index 00000000..e094235c --- /dev/null +++ b/examples/global-services/e2e/util/epcc-client.ts @@ -0,0 +1,12 @@ +import { gateway, MemoryStorageFactory } from "@elasticpath/js-sdk"; + +const host = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL; +const client_id = process.env.NEXT_PUBLIC_EPCC_CLIENT_ID; + +export const client = gateway({ + client_id, + host, + throttleEnabled: true, + name: "implicit_client", + storage: new MemoryStorageFactory(), +}); diff --git a/examples/global-services/e2e/util/fill-form-field.ts b/examples/global-services/e2e/util/fill-form-field.ts new file mode 100644 index 00000000..92c6ce20 --- /dev/null +++ b/examples/global-services/e2e/util/fill-form-field.ts @@ -0,0 +1,48 @@ +import { FrameLocator, Page } from "@playwright/test"; + +export type FormInputValue = { + value: string; + fieldType: "input" | "select" | "combobox"; + options?: { exact?: boolean }; +}; +export type FormInput = Record; + +export async function fillAllFormFields( + page: Page | FrameLocator, + input: FormInput, +) { + const fillers = Object.keys(input).map((key) => { + return () => fillFormField(page, key, input[key]); + }); + + for (const filler of fillers) { + await filler(); + } +} + +export async function fillFormField( + page: Page | FrameLocator, + key: string, + { value, fieldType, options }: FormInputValue, +): Promise { + let locator; + if (fieldType === "combobox") { + locator = page.getByRole("combobox"); + } else { + locator = page.getByLabel(key, { exact: true, ...options }); + } + + switch (fieldType) { + case "input": + return locator.fill(value); + case "select": { + await locator.selectOption(value); + return; + } + case "combobox": { + await locator.click(); + await page.getByLabel(value).click(); + return; + } + } +} diff --git a/examples/global-services/e2e/util/gateway-check.ts b/examples/global-services/e2e/util/gateway-check.ts new file mode 100644 index 00000000..ceacf531 --- /dev/null +++ b/examples/global-services/e2e/util/gateway-check.ts @@ -0,0 +1,13 @@ +import type {ElasticPath } from "@elasticpath/js-sdk"; + +export async function gatewayCheck(client: ElasticPath): Promise { + try { + const gateways = await client.Gateways.All(); + const epPaymentGateway = gateways.data.find( + (gateway) => gateway.slug === "elastic_path_payments_stripe", + )?.enabled; + return !!epPaymentGateway; + } catch (err) { + return false; + } +} diff --git a/examples/global-services/e2e/util/gateway-is-enabled.ts b/examples/global-services/e2e/util/gateway-is-enabled.ts new file mode 100644 index 00000000..12e55d3a --- /dev/null +++ b/examples/global-services/e2e/util/gateway-is-enabled.ts @@ -0,0 +1,10 @@ +import { test } from "@playwright/test"; +import { gatewayCheck } from "./gateway-check"; +import { adminClient } from "./epcc-admin-client"; + +export async function gatewayIsEnabled(): Promise { + test.skip( + !(await gatewayCheck(adminClient)), + "Skipping tests because they payment gateway is not enabled.", + ); +} diff --git a/examples/global-services/e2e/util/get-cart-id.ts b/examples/global-services/e2e/util/get-cart-id.ts new file mode 100644 index 00000000..e2e7dc8d --- /dev/null +++ b/examples/global-services/e2e/util/get-cart-id.ts @@ -0,0 +1,14 @@ +import { expect, Page } from "@playwright/test"; + +export function getCartId(page: Page) { + return async function _getCartId(): Promise { + /* Get the cart id from the cookie */ + const allCookies = await page.context().cookies(); + const cartId = allCookies.find( + (cookie) => cookie.name === "_store_ep_cart", + )?.value; + + expect(cartId).toBeDefined(); + return cartId!; + }; +} diff --git a/examples/global-services/e2e/util/has-published-catalog.ts b/examples/global-services/e2e/util/has-published-catalog.ts new file mode 100644 index 00000000..983fc901 --- /dev/null +++ b/examples/global-services/e2e/util/has-published-catalog.ts @@ -0,0 +1,12 @@ +import type {ElasticPath } from "@elasticpath/js-sdk"; + +export async function hasPublishedCatalog( + client: ElasticPath, +): Promise { + try { + await client.ShopperCatalog.Get(); + return false; + } catch (err) { + return true; + } +} diff --git a/examples/global-services/e2e/util/missing-published-catalog.ts b/examples/global-services/e2e/util/missing-published-catalog.ts new file mode 100644 index 00000000..d48fec22 --- /dev/null +++ b/examples/global-services/e2e/util/missing-published-catalog.ts @@ -0,0 +1,10 @@ +import { test } from "@playwright/test"; +import { hasPublishedCatalog } from "./has-published-catalog"; +import { client } from "./epcc-client"; + +export async function skipIfMissingCatalog(): Promise { + test.skip( + await hasPublishedCatalog(client), + "Skipping tests because there is no published catalog.", + ); +} diff --git a/examples/global-services/e2e/util/resolver-product-from-store.ts b/examples/global-services/e2e/util/resolver-product-from-store.ts new file mode 100644 index 00000000..3005dd7c --- /dev/null +++ b/examples/global-services/e2e/util/resolver-product-from-store.ts @@ -0,0 +1,84 @@ +import type { + ElasticPath, + ShopperCatalogResourcePage, + ProductResponse, + ShopperCatalogResource, +} from "@elasticpath/js-sdk"; + +export async function getSimpleProduct( + client: ElasticPath, +): Promise { + const paginator = paginateShopperProducts(client, { limit: 100 }); + + if (paginator) { + for await (const page of paginator) { + const simpleProduct = page.data.find( + (x) => !x.attributes.base_product && !x.attributes.base_product_id, + ); + if (simpleProduct) { + return simpleProduct; + } + } + } +} + +export async function getProductById( + client: ElasticPath, + productId: string, +): Promise> { + return client.ShopperCatalog.Products.Get({ + productId: productId, + }); +} + +export async function getVariationsProduct( + client: ElasticPath, +): Promise { + const paginator = paginateShopperProducts(client, { limit: 100 }); + + if (paginator) { + for await (const page of paginator) { + const variationsProduct = page.data.find( + (x) => x.attributes.base_product, + ); + if (variationsProduct) { + return variationsProduct; + } + } + } +} + +const makePagedClientRequest = async ( + client: ElasticPath, + { limit = 100, offset }: { limit?: number; offset: number }, +): Promise> => { + return await client.ShopperCatalog.Products.Offset(offset).Limit(limit).All(); +}; + +export type Paginator = AsyncGenerator; + +export async function* paginateShopperProducts( + client: ElasticPath, + input: { limit?: number; offset?: number }, +): Paginator> | undefined { + let page: ShopperCatalogResourcePage; + + let nextOffset: number = input.offset ?? 0; + let hasNext = true; + + while (hasNext) { + page = await makePagedClientRequest(client, { + limit: input.limit, + offset: nextOffset, + }); + yield page; + const { + results: { total: totalItems }, + page: { current, limit }, + } = page.meta; + hasNext = current * limit < totalItems; + nextOffset = nextOffset + limit; + } + + return undefined; +} diff --git a/examples/global-services/e2e/util/skip-ci-env.ts b/examples/global-services/e2e/util/skip-ci-env.ts new file mode 100644 index 00000000..1f93e2d5 --- /dev/null +++ b/examples/global-services/e2e/util/skip-ci-env.ts @@ -0,0 +1,8 @@ +import { test } from "@playwright/test"; + +export function skipIfCIEnvironment(): void { + test.skip( + process.env.CI === "true", + "Skipping tests because we are in a CI environment.", + ); +} diff --git a/examples/global-services/license.md b/examples/global-services/license.md new file mode 100644 index 00000000..714fa3a8 --- /dev/null +++ b/examples/global-services/license.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Elastic Path Software Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/global-services/next-env.d.ts b/examples/global-services/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/examples/global-services/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/examples/global-services/next.config.js b/examples/global-services/next.config.js new file mode 100644 index 00000000..586e89a7 --- /dev/null +++ b/examples/global-services/next.config.js @@ -0,0 +1,38 @@ +// @ts-check + +/** + * @type {import('next').NextConfig} + **/ +const nextConfig = { + images: { + formats: ["image/avif", "image/webp"], + remotePatterns: [ + { + protocol: "https", + hostname: "**.epusercontent.com", + }, + { + protocol: "https", + hostname: "**.cm.elasticpath.com", + }, + ], + }, + i18n: { + locales: ["en"], + defaultLocale: "en", + }, + webpack(config) { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + }; + + return config; + }, +}; + +const withBundleAnalyzer = require("@next/bundle-analyzer")({ + enabled: process.env.ANALYZE === "true", +}); + +module.exports = withBundleAnalyzer(nextConfig); diff --git a/examples/global-services/package.json b/examples/global-services/package.json new file mode 100644 index 00000000..d4e078fa --- /dev/null +++ b/examples/global-services/package.json @@ -0,0 +1,93 @@ +{ + "name": "global-services", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "lint:fix": "next lint --fix", + "format:check": "prettier --check .", + "format:fix": "prettier --write .", + "type:check": "tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest", + "test:ci:e2e": "NODE_ENV=test pnpm build && (pnpm start & (sleep 5 && npx playwright install --with-deps && pnpm test:e2e && kill $(lsof -t -i tcp:3000)))", + "test:e2e": "NODE_ENV=test playwright test", + "build:e2e": "NODE_ENV=test next build", + "start:e2e": "NODE_ENV=test next start" + }, + "dependencies": { + "@elasticpath/js-sdk": "5.0.0", + "@elasticpath/react-shopper-hooks": "workspace:*", + "@floating-ui/react": "^0.26.3", + "@headlessui/react": "^1.7.17", + "@heroicons/react": "^2.0.18", + "@hookform/error-message": "^2.0.1", + "@hookform/resolvers": "^3.3.2", + "@klevu/core": "5.2.2", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@tanstack/react-query": "^5.51.23", + "@types/lodash": "4.17.7", + "class-variance-authority": "^0.7.0", + "clsx": "^1.2.1", + "cookies-next": "^4.0.0", + "focus-visible": "^5.2.0", + "formik": "^2.2.9", + "lodash": "4.17.21", + "next": "^14.0.0", + "pure-react-carousel": "^1.29.0", + "rc-slider": "^10.3.0", + "react": "^18.3.1", + "react-device-detect": "^2.2.2", + "react-dom": "^18.3.1", + "react-hook-form": "^7.49.0", + "react-toastify": "^9.1.3", + "server-only": "^0.0.1", + "tailwind-clip-path": "^1.0.0", + "tailwind-merge": "^2.0.0", + "zod": "^3.22.4", + "zod-formik-adapter": "^1.2.0" + }, + "devDependencies": { + "@babel/core": "^7.18.10", + "@next/bundle-analyzer": "^14.0.0", + "@next/env": "^14.0.0", + "@svgr/webpack": "^6.3.1", + "@types/node": "18.7.3", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "babel-loader": "^8.2.5", + "eslint": "^8.49.0", + "eslint-config-next": "^14.0.0", + "eslint-config-prettier": "^9.0.0", + "encoding": "^0.1.13", + "eslint-plugin-react": "^7.33.2", + "vite": "^4.2.1", + "vitest": "^0.34.5", + "@vitest/coverage-istanbul": "^0.34.5", + "@testing-library/jest-dom": "^6.1.3", + "@testing-library/react": "^14.0.0", + "@playwright/test": "^1.28.1", + "lint-staged": "^13.0.3", + "prettier": "^3.0.3", + "prettier-eslint": "^15.0.1", + "prettier-eslint-cli": "^7.1.0", + "typescript": "^5.2.2", + "tailwindcss": "^3.3.3", + "autoprefixer": "^10.4.14", + "postcss": "^8.4.30", + "prettier-plugin-tailwindcss": "^0.5.4", + "@tailwindcss/forms": "^0.5.7", + "tailwindcss-animate": "^1.0.7" + } +} \ No newline at end of file diff --git a/examples/global-services/playwright.config.ts b/examples/global-services/playwright.config.ts new file mode 100644 index 00000000..86a23119 --- /dev/null +++ b/examples/global-services/playwright.config.ts @@ -0,0 +1,72 @@ +import { PlaywrightTestConfig, devices } from "@playwright/test"; +import { join } from "path"; +import { loadEnvConfig } from "@next/env"; + +loadEnvConfig(process.env.PWD!); + +// Use process.env.PORT by default and fallback to port 3000 +const PORT = process.env.PORT || 3000; + +// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port +const baseURL = process.env.BASE_URL ?? `http://localhost:${PORT}`; + +// Reference: https://playwright.dev/docs/test-configuration +const config: PlaywrightTestConfig = { + // Timeout per test + timeout: 15 * 1000, + // Test directory + testDir: join(__dirname, "e2e"), + // If a test fails, retry it additional 2 times + retries: 2, + // Artifacts folder where screenshots, videos, and traces are stored. + outputDir: "test-results/", + + // Run your local dev server before starting the tests: + // https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests + // webServer: { + // command: 'yarn run dev', + // url: baseURL, + // timeout: 120 * 1000, + // reuseExistingServer: !process.env.CI, + // }, + + use: { + // Use baseURL so to make navigations relative. + // More information: https://playwright.dev/docs/api/class-testoptions#test-options-base-url + baseURL, + + screenshot: "only-on-failure", + + // Retry a test if its failing with enabled tracing. This allows you to analyse the DOM, console logs, network traffic etc. + // More information: https://playwright.dev/docs/trace-viewer + trace: "retry-with-trace", + + // All available context options: https://playwright.dev/docs/api/class-browser#browser-new-context + // contextOptions: { + // ignoreHTTPSErrors: true, + // }, + }, + + projects: [ + { + name: "Desktop Chrome", + use: { + ...devices["Desktop Chrome"], + }, + }, + { + name: "Desktop Firefox", + use: { + ...devices["Desktop Firefox"], + }, + }, + // Test against mobile viewports. + { + name: "Mobile Chrome", + use: { + ...devices["Pixel 5"], + }, + }, + ], +}; +export default config; diff --git a/examples/global-services/postcss.config.js b/examples/global-services/postcss.config.js new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/examples/global-services/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/examples/global-services/public/favicon.ico b/examples/global-services/public/favicon.ico new file mode 100644 index 00000000..a61f60f1 Binary files /dev/null and b/examples/global-services/public/favicon.ico differ diff --git a/examples/global-services/src/app/(auth)/account-member-credentials-schema.ts b/examples/global-services/src/app/(auth)/account-member-credentials-schema.ts new file mode 100644 index 00000000..19e7a5fc --- /dev/null +++ b/examples/global-services/src/app/(auth)/account-member-credentials-schema.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export const accountMemberCredentialSchema = z.object({ + account_id: z.string(), + account_name: z.string(), + expires: z.string(), + token: z.string(), + type: z.literal("account_management_authentication_token"), +}); + +export type AccountMemberCredential = z.infer< + typeof accountMemberCredentialSchema +>; + +export const accountMemberCredentialsSchema = z.object({ + accounts: z.record(z.string(), accountMemberCredentialSchema), + selected: z.string(), + accountMemberId: z.string(), +}); + +export type AccountMemberCredentials = z.infer< + typeof accountMemberCredentialsSchema +>; diff --git a/examples/global-services/src/app/(auth)/actions.ts b/examples/global-services/src/app/(auth)/actions.ts new file mode 100644 index 00000000..0e18f0d6 --- /dev/null +++ b/examples/global-services/src/app/(auth)/actions.ts @@ -0,0 +1,200 @@ +"use server"; + +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { z } from "zod"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../lib/cookie-constants"; +import { AccountTokenBase, ResourcePage } from "@elasticpath/js-sdk"; +import { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; +import { + AccountMemberCredential, + AccountMemberCredentials, +} from "./account-member-credentials-schema"; +import { retrieveAccountMemberCredentials } from "../../lib/retrieve-account-member-credentials"; +import { revalidatePath } from "next/cache"; +import { getErrorMessage } from "../../lib/get-error-message"; + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string(), + returnUrl: z.string().optional(), +}); + +const selectedAccountSchema = z.object({ + accountId: z.string(), +}); + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string(), + name: z.string(), +}); + +const PASSWORD_PROFILE_ID = process.env.NEXT_PUBLIC_PASSWORD_PROFILE_ID!; + +const loginErrorMessage = + "Failed to login, make sure your email and password are correct"; + +export async function login(props: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(props.entries()); + + const validatedProps = loginSchema.safeParse(rawEntries); + + if (!validatedProps.success) { + return { + error: loginErrorMessage, + }; + } + + const { email, password, returnUrl } = validatedProps.data; + + try { + const result = await client.AccountMembers.GenerateAccountToken({ + type: "account_management_authentication_token", + authentication_mechanism: "password", + password_profile_id: PASSWORD_PROFILE_ID, + username: email.toLowerCase(), // Known bug for uppercase usernames so we force lowercase. + password, + }); + + const cookieStore = cookies(); + cookieStore.set(createCookieFromGenerateTokenResponse(result)); + } catch (error) { + console.error(getErrorMessage(error)); + return { + error: loginErrorMessage, + }; + } + + redirect(returnUrl ?? "/"); +} + +export async function logout() { + const cookieStore = cookies(); + + cookieStore.delete(ACCOUNT_MEMBER_TOKEN_COOKIE_NAME); + + redirect("/"); +} + +export async function selectedAccount(args: FormData) { + const rawEntries = Object.fromEntries(args.entries()); + + const validatedProps = selectedAccountSchema.safeParse(rawEntries); + + if (!validatedProps.success) { + throw new Error("Invalid account id"); + } + + const { accountId } = validatedProps.data; + + const cookieStore = cookies(); + + const accountMemberCredentials = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCredentials) { + redirect("/login"); + return; + } + + const selectedAccount = accountMemberCredentials?.accounts[accountId]; + + if (!selectedAccount) { + throw new Error("Invalid account id"); + } + + cookieStore.set({ + name: ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + value: JSON.stringify({ + ...accountMemberCredentials, + selected: selectedAccount.account_id, + }), + path: "/", + sameSite: "strict", + expires: new Date( + accountMemberCredentials.accounts[ + Object.keys(accountMemberCredentials.accounts)[0] + ].expires, + ), + }); + + revalidatePath("/account"); +} + +export async function register(data: FormData) { + const client = getServerSideImplicitClient(); + + const validatedProps = registerSchema.safeParse( + Object.fromEntries(data.entries()), + ); + + if (!validatedProps.success) { + throw new Error("Invalid email or password or name"); + } + + const { email, password, name } = validatedProps.data; + + const result = await client.AccountMembers.GenerateAccountToken({ + type: "account_management_authentication_token", + authentication_mechanism: "self_signup", + password_profile_id: PASSWORD_PROFILE_ID, + username: email.toLowerCase(), // Known bug for uppercase usernames so we force lowercase. + password, + name, // TODO update sdk types as name should exist + email, + } as any); + + const cookieStore = cookies(); + cookieStore.set(createCookieFromGenerateTokenResponse(result)); + + redirect("/"); +} + +function createCookieFromGenerateTokenResponse( + response: ResourcePage, +): ResponseCookie { + const { expires } = response.data[0]; // assuming all tokens have shared expiration date/time + + const cookieValue = createAccountMemberCredentialsCookieValue( + response.data, + (response.meta as unknown as { account_member_id: string }) + .account_member_id, // TODO update sdk types + ); + + return { + name: ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + value: JSON.stringify(cookieValue), + path: "/", + sameSite: "strict", + expires: new Date(expires), + }; +} + +function createAccountMemberCredentialsCookieValue( + responseTokens: AccountTokenBase[], + accountMemberId: string, +): AccountMemberCredentials { + return { + accounts: responseTokens.reduce( + (acc, responseToken) => ({ + ...acc, + [responseToken.account_id]: { + account_id: responseToken.account_id, + account_name: responseToken.account_name, + expires: responseToken.expires, + token: responseToken.token, + type: "account_management_authentication_token" as const, + }, + }), + {} as Record, + ), + selected: responseTokens[0].account_id, + accountMemberId, + }; +} diff --git a/examples/global-services/src/app/(auth)/layout.tsx b/examples/global-services/src/app/(auth)/layout.tsx new file mode 100644 index 00000000..b655a9bc --- /dev/null +++ b/examples/global-services/src/app/(auth)/layout.tsx @@ -0,0 +1,51 @@ +import { Inter } from "next/font/google"; +import { ReactNode } from "react"; +import { getStoreInitialState } from "../../lib/get-store-initial-state"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { Providers } from "../providers"; +import clsx from "clsx"; + +const { SITE_NAME } = process.env; +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +export const metadata = { + metadataBase: new URL(baseUrl), + title: { + default: SITE_NAME!, + template: `%s | ${SITE_NAME}`, + }, + robots: { + follow: true, + index: true, + }, +}; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export default async function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + const client = getServerSideImplicitClient(); + const initialState = await getStoreInitialState(client); + + return ( + + + {/* headless ui needs this div - https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1745272229 */} +
+ +
{children}
+
+
+ + + ); +} diff --git a/examples/global-services/src/app/(auth)/login/LoginForm.tsx b/examples/global-services/src/app/(auth)/login/LoginForm.tsx new file mode 100644 index 00000000..a89c4a5c --- /dev/null +++ b/examples/global-services/src/app/(auth)/login/LoginForm.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { login } from "../actions"; +import { Label } from "../../../components/label/Label"; +import { Input } from "../../../components/input/Input"; +import { FormStatusButton } from "../../../components/button/FormStatusButton"; +import { useState } from "react"; + +export function LoginForm({ returnUrl }: { returnUrl?: string }) { + const [error, setError] = useState(undefined); + + async function loginAction(formData: FormData) { + const result = await login(formData); + + if ("error" in result) { + setError(result.error); + } + } + + return ( +
+
+ +
+ +
+
+ +
+
+ + +
+
+ +
+
+ {returnUrl && ( + + )} + {error && ( +
+ {error} +
+ )} + +
+ Login +
+
+ ); +} diff --git a/examples/global-services/src/app/(auth)/login/page.tsx b/examples/global-services/src/app/(auth)/login/page.tsx new file mode 100644 index 00000000..7f814ed2 --- /dev/null +++ b/examples/global-services/src/app/(auth)/login/page.tsx @@ -0,0 +1,49 @@ +import EpLogo from "../../../components/icons/ep-logo"; +import { cookies } from "next/headers"; +import { isAccountMemberAuthenticated } from "../../../lib/is-account-member-authenticated"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { LoginForm } from "./LoginForm"; + +export default function Login({ + searchParams, +}: { + searchParams: { returnUrl?: string }; +}) { + const { returnUrl } = searchParams; + + const cookieStore = cookies(); + + if (isAccountMemberAuthenticated(cookieStore)) { + redirect("/account/summary"); + } + + return ( + <> +
+
+ + + +

+ Sign in to your account +

+
+ +
+ + +

+ Not a member?{" "} + + Register now! + +

+
+
+ + ); +} diff --git a/examples/global-services/src/app/(auth)/not-found.tsx b/examples/global-services/src/app/(auth)/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/global-services/src/app/(auth)/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
+ + 404 - The page could not be found. + + + Back to home + +
+ ); +} diff --git a/examples/global-services/src/app/(auth)/register/page.tsx b/examples/global-services/src/app/(auth)/register/page.tsx new file mode 100644 index 00000000..125ad4e6 --- /dev/null +++ b/examples/global-services/src/app/(auth)/register/page.tsx @@ -0,0 +1,97 @@ +import { register } from "../actions"; +import EpLogo from "../../../components/icons/ep-logo"; +import { cookies } from "next/headers"; +import { isAccountMemberAuthenticated } from "../../../lib/is-account-member-authenticated"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { Label } from "../../../components/label/Label"; +import { Input } from "../../../components/input/Input"; +import { FormStatusButton } from "../../../components/button/FormStatusButton"; + +export default function Register() { + const cookieStore = cookies(); + if (isAccountMemberAuthenticated(cookieStore)) { + redirect("/account/summary"); + } + + return ( + <> +
+
+ + + +

+ Register for an account +

+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+ Register +
+
+ +

+ Already a member?{" "} + + Login now! + +

+
+
+ + ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/AccountCheckout.tsx b/examples/global-services/src/app/(checkout)/checkout/AccountCheckout.tsx new file mode 100644 index 00000000..e85d1651 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/AccountCheckout.tsx @@ -0,0 +1,52 @@ +import Link from "next/link"; +import EpIcon from "../../../components/icons/ep-icon"; +import { Separator } from "../../../components/separator/Separator"; +import { DeliveryForm } from "./DeliveryForm"; +import { PaymentForm } from "./PaymentForm"; +import { BillingForm } from "./BillingForm"; +import { SubmitCheckoutButton } from "./SubmitCheckoutButton"; +import { CheckoutSidebar } from "./CheckoutSidebar"; +import { AccountDisplay } from "./AccountDisplay"; +import { ShippingSelector } from "./ShippingSelector"; + +export function AccountCheckout() { + return ( +
+
+ + + +
+
+
+
+ + + +
+ +
+ Your Info + +
+
+ Shipping address + +
+ + +
+ +
+
+ +
+
+
+ {/* Sidebar */} + +
+
+
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/AccountDisplay.tsx b/examples/global-services/src/app/(checkout)/checkout/AccountDisplay.tsx new file mode 100644 index 00000000..47f400f4 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/AccountDisplay.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useAuthedAccountMember } from "@elasticpath/react-shopper-hooks"; +import { Button } from "../../../components/button/Button"; +import { FormControl, FormField } from "../../../components/form/Form"; +import { Input } from "../../../components/input/Input"; +import React, { useEffect, useTransition } from "react"; +import { useFormContext } from "react-hook-form"; +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { logout } from "../../(auth)/actions"; +import { Skeleton } from "../../../components/skeleton/Skeleton"; + +export function AccountDisplay() { + const { data: accountMember } = useAuthedAccountMember(); + + const { control, setValue } = useFormContext(); + + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + if (accountMember?.email && accountMember?.name) { + setValue("account", { + email: accountMember.email, + name: accountMember.name, + }); + } + }, [accountMember, setValue]); + + return ( +
+
+ {accountMember ? ( + <> + {accountMember?.name} + {accountMember?.email} + + ) : ( +
+ + +
+ )} +
+ + {accountMember && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )} +
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/BillingForm.tsx b/examples/global-services/src/app/(checkout)/checkout/BillingForm.tsx new file mode 100644 index 00000000..0804c54c --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/BillingForm.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { Checkbox } from "../../../components/Checkbox"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { useFormContext, useWatch } from "react-hook-form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/select/Select"; +import { Input } from "../../../components/input/Input"; +import React, { useEffect } from "react"; +import { useCountries } from "../../../hooks/use-countries"; + +export function BillingForm() { + const { control, resetField } = useFormContext(); + const { data: countries } = useCountries(); + const isSameAsShipping = useWatch({ control, name: "sameAsShipping" }); + + useEffect(() => { + // Reset the billing address fields when the user selects the same as shipping address + if (isSameAsShipping) { + resetField("billingAddress", { + keepDirty: false, + keepTouched: false, + keepError: false, + }); + } + }, [isSameAsShipping, resetField]); + + return ( +
+
+ Billing address +
+
+ ( + + + + +
+ Same as shipping address +
+
+ )} + /> +
+ {!isSameAsShipping && ( +
+ ( + + Country + + + + )} + /> +
+ ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
+ ( + + Company (optional) + + + + + + )} + /> + ( + + Address + + + + + + )} + /> +
+ ( + + City + + + + + + )} + /> + ( + + Region + + + + + + )} + /> + ( + + Postcode + + + + + + )} + /> +
+
+ )} +
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/CheckoutFooter.tsx b/examples/global-services/src/app/(checkout)/checkout/CheckoutFooter.tsx new file mode 100644 index 00000000..9882b3a1 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/CheckoutFooter.tsx @@ -0,0 +1,30 @@ +import { Separator } from "../../../components/separator/Separator"; +import Link from "next/link"; +import EpLogo from "../../../components/icons/ep-logo"; +import * as React from "react"; + +export function CheckoutFooter() { + return ( +
+ +
+ + Refund Policy + + + Shipping Policy + + + Privacy Policy + + + Terms of Service + +
+
+ Powered by + +
+
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/CheckoutSidebar.tsx b/examples/global-services/src/app/(checkout)/checkout/CheckoutSidebar.tsx new file mode 100644 index 00000000..d3e3f4ea --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/CheckoutSidebar.tsx @@ -0,0 +1,103 @@ +"use client"; +import { Separator } from "../../../components/separator/Separator"; +import { CartDiscounts } from "../../../components/cart/CartDiscounts"; +import * as React from "react"; +import { useCart, useCurrencies } from "@elasticpath/react-shopper-hooks"; +import { + ItemSidebarHideable, + ItemSidebarItems, + ItemSidebarPromotions, + ItemSidebarTotals, + ItemSidebarTotalsDiscount, + ItemSidebarTotalsSubTotal, + ItemSidebarTotalsTax, + resolveTotalInclShipping, +} from "../../../components/checkout-sidebar/ItemSidebar"; +import { staticDeliveryMethods } from "./useShippingMethod"; +import { cn } from "../../../lib/cn"; +import { useWatch } from "react-hook-form"; +import { EP_CURRENCY_CODE } from "../../../lib/resolve-ep-currency-code"; +import { formatCurrency } from "../../../lib/format-currency"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export function CheckoutSidebar() { + const { data } = useCart(); + const state = data?.state; + const shippingMethod = useWatch({ name: "shippingMethod" }); + + const { data: currencyData } = useCurrencies(); + + const storeCurrency = currencyData?.find( + (currency) => currency.code === EP_CURRENCY_CODE, + ); + + if (!state) { + return null; + } + + const shippingAmount = staticDeliveryMethods.find( + (method) => method.value === shippingMethod, + )?.amount; + + const { meta, __extended } = state; + + const formattedTotalAmountInclShipping = + meta?.display_price?.with_tax?.amount !== undefined && + shippingAmount !== undefined && + storeCurrency + ? resolveTotalInclShipping( + shippingAmount, + meta.display_price.with_tax.amount, + storeCurrency, + ) + : undefined; + + return ( + +
+ + + + + {/* Totals */} + + +
+ Shipping + + {shippingAmount === undefined ? ( + "Select delivery method" + ) : storeCurrency ? ( + formatCurrency(shippingAmount, storeCurrency) + ) : ( + + )} + +
+ + +
+ + {/* Sum total incl shipping */} + {formattedTotalAmountInclShipping ? ( +
+ Total +
+ {meta?.display_price?.with_tax?.currency} + + {formattedTotalAmountInclShipping} + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/CheckoutViews.tsx b/examples/global-services/src/app/(checkout)/checkout/CheckoutViews.tsx new file mode 100644 index 00000000..d88c5115 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/CheckoutViews.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useCheckout } from "./checkout-provider"; +import { ReactNode } from "react"; +import { OrderConfirmation } from "./OrderConfirmation"; + +export function CheckoutViews({ children }: { children: ReactNode }) { + const { confirmationData } = useCheckout(); + + if (confirmationData) { + return ; + } + + return children; +} diff --git a/examples/global-services/src/app/(checkout)/checkout/ConfirmationSidebar.tsx b/examples/global-services/src/app/(checkout)/checkout/ConfirmationSidebar.tsx new file mode 100644 index 00000000..708d1549 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/ConfirmationSidebar.tsx @@ -0,0 +1,108 @@ +"use client"; +import { Separator } from "../../../components/separator/Separator"; +import { CartDiscountsReadOnly } from "../../../components/cart/CartDiscounts"; +import * as React from "react"; +import { + groupCartItems, + useCurrencies, +} from "@elasticpath/react-shopper-hooks"; +import { + ItemSidebarHideable, + ItemSidebarItems, + ItemSidebarTotals, + ItemSidebarTotalsDiscount, + ItemSidebarTotalsSubTotal, + ItemSidebarTotalsTax, + resolveTotalInclShipping, +} from "../../../components/checkout-sidebar/ItemSidebar"; +import { useCheckout } from "./checkout-provider"; +import { staticDeliveryMethods } from "./useShippingMethod"; +import { EP_CURRENCY_CODE } from "../../../lib/resolve-ep-currency-code"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export function ConfirmationSidebar() { + const { confirmationData } = useCheckout(); + + const { data: currencyData } = useCurrencies(); + + if (!confirmationData) { + return null; + } + + const { order, cart } = confirmationData; + + const groupedItems = groupCartItems(cart.data); + + const shippingMethodCustomItem = groupedItems.custom.find((item) => + item.sku.startsWith("__shipping_"), + ); + + const meta = { + display_price: order.data.meta.display_price, + }; + + const shippingAmount = staticDeliveryMethods.find( + (method) => + !!shippingMethodCustomItem && + method.value === shippingMethodCustomItem.sku, + )?.amount; + + const storeCurrency = currencyData?.find( + (currency) => currency.code === EP_CURRENCY_CODE, + ); + + const formattedTotalAmountInclShipping = + meta?.display_price?.with_tax?.amount !== undefined && + shippingAmount !== undefined && + storeCurrency + ? resolveTotalInclShipping( + shippingAmount, + meta.display_price.with_tax.amount, + storeCurrency, + ) + : undefined; + + return ( + +
+
+ +
+ Discounts applied + + {/* Totals */} + + + {shippingMethodCustomItem && ( +
+ Shipping + + { + shippingMethodCustomItem.meta.display_price.with_tax.value + .formatted + } + +
+ )} + + +
+ + {/* Sum total incl shipping */} + {formattedTotalAmountInclShipping ? ( +
+ Total +
+ {meta?.display_price?.with_tax?.currency} + + {formattedTotalAmountInclShipping} + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/DeliveryForm.tsx b/examples/global-services/src/app/(checkout)/checkout/DeliveryForm.tsx new file mode 100644 index 00000000..baa58998 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/DeliveryForm.tsx @@ -0,0 +1,108 @@ +"use client"; +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { + RadioGroup, + RadioGroupItem, +} from "../../../components/radio-group/RadioGroup"; +import { Label } from "../../../components/label/Label"; +import { cn } from "../../../lib/cn"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { useFormContext } from "react-hook-form"; +import { useShippingMethod } from "./useShippingMethod"; +import { LightBulbIcon } from "@heroicons/react/24/outline"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "../../../components/alert/Alert"; +import { Skeleton } from "../../../components/skeleton/Skeleton"; + +export function DeliveryForm() { + const { control } = useFormContext(); + const { data: deliveryOptions } = useShippingMethod(); + + return ( +
+
+ Delivery +
+ + + Delivery is using fixed rates! + +

+ Delivery is fixed rate data for testing. You can replace this with a + 3rd party service. +

+
+
+ {!deliveryOptions ? ( +
+ + +
+ ) : ( + ( + + Shipping Method + + + {deliveryOptions.map((option, optionIndex) => { + return ( +
+
+ + +
+ {option.formatted} +
+ ); + })} +
+
+ +
+ )} + /> + )} +
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/FormInput.tsx b/examples/global-services/src/app/(checkout)/checkout/FormInput.tsx new file mode 100644 index 00000000..c50a2606 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/FormInput.tsx @@ -0,0 +1,73 @@ +import { ErrorMessage } from "@hookform/error-message"; +import React, { forwardRef, useImperativeHandle, useRef } from "react"; +import { get } from "react-hook-form"; +import { Label } from "../../../components/label/Label"; +import { Input } from "../../../components/input/Input"; +import { cn } from "../../../lib/cn"; + +type InputProps = Omit< + Omit, "size">, + "placeholder" +> & { + label: string; + errors?: Record; + touched?: Record; + name: string; +}; + +export const FormInput = forwardRef( + ({ type, name, label, errors, touched, required, ...props }, ref) => { + const inputRef = useRef(null); + + useImperativeHandle(ref, () => inputRef.current!); + + const hasError = get(errors, name) && get(touched, name); + + return ( +
+
+
+

+ + +

+
+
+ {hasError && ( + { + return ( +
+ {message} +
+ ); + }} + /> + )} +
+ ); + }, +); + +FormInput.displayName = "FormInput"; diff --git a/examples/global-services/src/app/(checkout)/checkout/GuestCheckout.tsx b/examples/global-services/src/app/(checkout)/checkout/GuestCheckout.tsx new file mode 100644 index 00000000..8fcb4655 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/GuestCheckout.tsx @@ -0,0 +1,51 @@ +import { GuestInformation } from "./GuestInformation"; +import { ShippingForm } from "./ShippingForm"; +import Link from "next/link"; +import EpIcon from "../../../components/icons/ep-icon"; +import { DeliveryForm } from "./DeliveryForm"; +import { PaymentForm } from "./PaymentForm"; +import { BillingForm } from "./BillingForm"; +import { SubmitCheckoutButton } from "./SubmitCheckoutButton"; +import { Separator } from "../../../components/separator/Separator"; +import * as React from "react"; +import { CheckoutSidebar } from "./CheckoutSidebar"; + +export async function GuestCheckout() { + return ( +
+
+ + + +
+
+
+
+ + + +
+ +
+ +
+
+ +
+ + +
+ +
+
+ +
+
+
+ {/* Sidebar */} + +
+
+
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/GuestInformation.tsx b/examples/global-services/src/app/(checkout)/checkout/GuestInformation.tsx new file mode 100644 index 00000000..fa22e559 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/GuestInformation.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { Input } from "../../../components/input/Input"; +import React from "react"; +import { useFormContext } from "react-hook-form"; + +export function GuestInformation() { + const pathname = usePathname(); + + const { control } = useFormContext(); + + return ( +
+
+ Your Info + + Already a customer?{" "} + + Sign in + + +
+ ( + + Email + + + + + + )} + /> +
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/OrderConfirmation.tsx b/examples/global-services/src/app/(checkout)/checkout/OrderConfirmation.tsx new file mode 100644 index 00000000..0391da30 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/OrderConfirmation.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useCheckout } from "./checkout-provider"; +import { ConfirmationSidebar } from "./ConfirmationSidebar"; +import Link from "next/link"; +import EpIcon from "../../../components/icons/ep-icon"; +import * as React from "react"; +import { Separator } from "../../../components/separator/Separator"; +import { CheckoutFooter } from "./CheckoutFooter"; +import { Button } from "../../../components/button/Button"; + +export function OrderConfirmation() { + const { confirmationData } = useCheckout(); + + if (!confirmationData) { + return null; + } + + const { order } = confirmationData; + + const customerName = ( + order.data.contact?.name ?? + order.data.customer.name ?? + "" + ).split(" ")[0]; + + const { shipping_address, id: orderId } = order.data; + + return ( +
+
+ + + +
+
+ {/* Confirmation Content */} +
+
+ + + +
+ + + Thanks{customerName ? ` ${customerName}` : ""}! + +
+ +
+ + Order #{orderId} is confirmed. + + {/* Shipping */} +
+

Ship to

+
+ {`${shipping_address.first_name} ${shipping_address.last_name}`} +

{shipping_address.line_1}

+ {`${shipping_address.region}, ${shipping_address.postcode}`} + {shipping_address.phone_number && ( + {shipping_address.phone_number} + )} +
+
+ {/* Delivery Method */} +
+

Delivery Method

+

placeholder

+
+ {/* Contact us */} +
+

Need to make changes?

+

+ Email us or call. Remember to reference order #{orderId} +

+
+ +
+ {/* Confirmation Sidebar */} +
+
+ {`Order #${orderId}`} + + +
+
+
+
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/PaymentForm.tsx b/examples/global-services/src/app/(checkout)/checkout/PaymentForm.tsx new file mode 100644 index 00000000..ad609d01 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/PaymentForm.tsx @@ -0,0 +1,30 @@ +"use client"; +import { LightBulbIcon } from "@heroicons/react/24/outline"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "../../../components/alert/Alert"; + +export function PaymentForm() { + return ( +
+
+ Payment +
+ + + Payments set to manual! + +

+ Manual payments are enabled. To test the checkout flow, configure an + alternate payment gateway to take real payments. +

+

+ To checkout with manual payments you can just complete the order. +

+
+
+
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/ShippingForm.tsx b/examples/global-services/src/app/(checkout)/checkout/ShippingForm.tsx new file mode 100644 index 00000000..5f9ab548 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/ShippingForm.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { useFormContext } from "react-hook-form"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { Input } from "../../../components/input/Input"; +import React from "react"; +import { useCountries } from "../../../hooks/use-countries"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/select/Select"; + +export function ShippingForm() { + const { control } = useFormContext(); + const { data: countries } = useCountries(); + + return ( +
+
+ Shipping address +
+
+ ( + + Country + + + + )} + /> +
+ ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
+ ( + + Company (optional) + + + + + + )} + /> + ( + + Address + + + + + + )} + /> +
+ ( + + City + + + + + + )} + /> + ( + + Region + + + + + + )} + /> + ( + + Postcode + + + + + + )} + /> +
+ ( + + Phone Number (optional) + + + + + + )} + /> +
+
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/ShippingSelector.tsx b/examples/global-services/src/app/(checkout)/checkout/ShippingSelector.tsx new file mode 100644 index 00000000..91f29d50 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/ShippingSelector.tsx @@ -0,0 +1,99 @@ +"use client"; +import { + useAccountAddresses, + useAuthedAccountMember, +} from "@elasticpath/react-shopper-hooks"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/select/Select"; +import { useFormContext, useWatch } from "react-hook-form"; +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { AccountAddress } from "@elasticpath/js-sdk"; +import { useEffect } from "react"; +import { Skeleton } from "../../../components/skeleton/Skeleton"; +import { Button } from "../../../components/button/Button"; +import Link from "next/link"; + +export function ShippingSelector() { + const { selectedAccountToken } = useAuthedAccountMember(); + + const { data: accountAddressData } = useAccountAddresses( + selectedAccountToken?.account_id!, + { + ep: { accountMemberToken: selectedAccountToken?.token }, + enabled: !!selectedAccountToken, + }, + ); + + const { setValue } = useFormContext(); + + function updateAddress(addressId: string, addresses: AccountAddress[]) { + const address = addresses.find((address) => address.id === addressId); + + if (address) { + setValue("shippingAddress", { + postcode: address.postcode, + line_1: address.line_1, + line_2: address.line_2, + city: address.city, + county: address.county, + country: address.country, + company_name: address.company_name, + first_name: address.first_name, + last_name: address.last_name, + phone_number: address.phone_number, + instructions: address.instructions, + region: address.region, + }); + } + } + + useEffect(() => { + if (accountAddressData && accountAddressData[0]) { + updateAddress(accountAddressData[0].id, accountAddressData); + } + }, [accountAddressData]); + + return ( +
+ {accountAddressData ? ( + + ) : ( + + )} +
+ ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx b/examples/global-services/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx new file mode 100644 index 00000000..d67880df --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useCheckout } from "./checkout-provider"; +import { useCart } from "@elasticpath/react-shopper-hooks"; +import { StatusButton } from "../../../components/button/StatusButton"; + +export function SubmitCheckoutButton() { + const { handleSubmit, completePayment, isCompleting } = useCheckout(); + const { data } = useCart(); + + const state = data?.state; + + if (!state) { + return null; + } + + return ( + { + completePayment.mutate({ data: values }); + })} + > + {`Pay ${state.meta?.display_price?.with_tax?.formatted}`} + + ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/checkout-provider.tsx b/examples/global-services/src/app/(checkout)/checkout/checkout-provider.tsx new file mode 100644 index 00000000..b9aa8216 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/checkout-provider.tsx @@ -0,0 +1,103 @@ +"use client"; + +import React, { createContext, useContext, useState } from "react"; +import { useForm, useFormContext } from "react-hook-form"; +import { + CheckoutForm, + checkoutFormSchema, +} from "../../../components/checkout/form-schema/checkout-form-schema"; +import { + CartState, + useAuthedAccountMember, + useCart, + useCartClear, +} from "@elasticpath/react-shopper-hooks"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Form } from "../../../components/form/Form"; +import { ShippingMethod, useShippingMethod } from "./useShippingMethod"; +import { usePaymentComplete } from "./usePaymentComplete"; + +type CheckoutContext = { + cart?: CartState; + isLoading: boolean; + completePayment: ReturnType; + isCompleting: boolean; + confirmationData: ReturnType["data"]; + shippingMethods: { + options?: ShippingMethod[]; + isLoading: boolean; + }; +}; + +const CheckoutContext = createContext(null); + +type CheckoutProviderProps = { + children?: React.ReactNode; +}; + +export function CheckoutProvider({ children }: CheckoutProviderProps) { + const { data } = useCart(); + + const state = data?.state; + + const { mutateAsync: mutateClearCart } = useCartClear(); + + const [confirmationData, setConfirmationData] = + useState["data"]>(undefined); + + const formMethods = useForm({ + reValidateMode: "onChange", + resolver: zodResolver(checkoutFormSchema), + defaultValues: { + sameAsShipping: true, + shippingMethod: "__shipping_standard", + }, + }); + + const { selectedAccountToken } = useAuthedAccountMember(); + + const { data: shippingMethods, isLoading: isShippingMethodsLoading } = + useShippingMethod(); + + const paymentComplete = usePaymentComplete( + { + cartId: state?.id, + accountToken: selectedAccountToken?.token, + }, + { + onSuccess: async (data) => { + setConfirmationData(data); + await mutateClearCart(); + }, + }, + ); + + return ( +
+ + {children} + +
+ ); +} + +export const useCheckout = () => { + const context = useContext(CheckoutContext); + const form = useFormContext(); + if (context === null) { + throw new Error("useCheckout must be used within a CheckoutProvider"); + } + return { ...context, ...form }; +}; diff --git a/examples/global-services/src/app/(checkout)/checkout/page.tsx b/examples/global-services/src/app/(checkout)/checkout/page.tsx new file mode 100644 index 00000000..df42df74 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/page.tsx @@ -0,0 +1,44 @@ +import { Metadata } from "next"; +import { AccountCheckout } from "./AccountCheckout"; +import { retrieveAccountMemberCredentials } from "../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../lib/cookie-constants"; +import { GuestCheckout } from "./GuestCheckout"; +import { cookies } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { COOKIE_PREFIX_KEY } from "../../../lib/resolve-cart-env"; +import { getEpccImplicitClient } from "../../../lib/epcc-implicit-client"; +import { CheckoutProvider } from "./checkout-provider"; +import { CheckoutViews } from "./CheckoutViews"; + +export const metadata: Metadata = { + title: "Checkout", +}; +export default async function CheckoutPage() { + const cookieStore = cookies(); + + const cartCookie = cookieStore.get(`${COOKIE_PREFIX_KEY}_ep_cart`); + const client = getEpccImplicitClient(); + + const cart = await client.Cart(cartCookie?.value).With("items").Get(); + + if (!cart) { + notFound(); + } + + if ((cart.included?.items?.length ?? 0) < 1) { + redirect("/cart"); + } + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + return ( + + + {!accountMemberCookie ? : } + + + ); +} diff --git a/examples/global-services/src/app/(checkout)/checkout/usePaymentComplete.tsx b/examples/global-services/src/app/(checkout)/checkout/usePaymentComplete.tsx new file mode 100644 index 00000000..78123154 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/usePaymentComplete.tsx @@ -0,0 +1,129 @@ +import { + useAddCustomItemToCart, + useCheckout as useCheckoutCart, + useCheckoutWithAccount, + usePayments, +} from "@elasticpath/react-shopper-hooks"; +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { CheckoutForm } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { staticDeliveryMethods } from "./useShippingMethod"; +import { + CartItemsResponse, + ConfirmPaymentResponse, + Order, + Resource, +} from "@elasticpath/js-sdk"; + +export type UsePaymentCompleteProps = { + cartId: string | undefined; + accountToken?: string; +}; + +export type UsePaymentCompleteReq = { + data: CheckoutForm; +}; + +export function usePaymentComplete( + { cartId, accountToken }: UsePaymentCompleteProps, + options?: UseMutationOptions< + { + order: Resource; + payment: ConfirmPaymentResponse; + cart: CartItemsResponse; + }, + unknown, + UsePaymentCompleteReq + >, +) { + const { mutateAsync: mutatePayment } = usePayments(); + const { mutateAsync: mutateConvertToOrder } = useCheckoutCart(cartId ?? ""); + const { mutateAsync: mutateConvertToOrderAsAccount } = useCheckoutWithAccount( + cartId ?? "", + ); + const { mutateAsync: mutateAddCustomItemToCart } = useAddCustomItemToCart( + cartId ?? "", + ); + + const paymentComplete = useMutation({ + mutationFn: async ({ data }) => { + const { + shippingAddress, + billingAddress, + sameAsShipping, + shippingMethod, + } = data; + + const customerName = `${shippingAddress.first_name} ${shippingAddress.last_name}`; + + const checkoutProps = { + billingAddress: + billingAddress && !sameAsShipping ? billingAddress : shippingAddress, + shippingAddress: shippingAddress, + }; + + /** + * The handling of shipping options is not production ready. + * You must implement your own based on your business needs. + */ + const shippingAmount = + staticDeliveryMethods.find((method) => method.value === shippingMethod) + ?.amount ?? 0; + + /** + * Using a cart custom_item to represent shipping for demo purposes. + */ + const cartInclShipping = await mutateAddCustomItemToCart({ + type: "custom_item", + name: "Shipping", + sku: shippingMethod, + quantity: 1, + price: { + amount: shippingAmount, + includes_tax: true, + }, + }); + + /** + * 1. Convert our cart to an order we can pay + */ + const createdOrder = await ("guest" in data + ? mutateConvertToOrder({ + customer: { + email: data.guest.email, + name: customerName, + }, + ...checkoutProps, + }) + : mutateConvertToOrderAsAccount({ + contact: { + name: data.account.name, + email: data.account.email, + }, + token: accountToken ?? "", + ...checkoutProps, + })); + + /** + * 2. Perform payment against the order + */ + const confirmedPayment = await mutatePayment({ + orderId: createdOrder.data.id, + payment: { + gateway: "manual", + method: "purchase", + }, + }); + + return { + order: createdOrder, + payment: confirmedPayment, + cart: cartInclShipping, + }; + }, + ...options, + }); + + return { + ...paymentComplete, + }; +} diff --git a/examples/global-services/src/app/(checkout)/checkout/useShippingMethod.tsx b/examples/global-services/src/app/(checkout)/checkout/useShippingMethod.tsx new file mode 100644 index 00000000..9703ec2b --- /dev/null +++ b/examples/global-services/src/app/(checkout)/checkout/useShippingMethod.tsx @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query"; + +export type ShippingMethod = { + label: string; + value: string; + amount: number; + formatted: string; +}; + +export const staticDeliveryMethods: ShippingMethod[] = [ + { + label: "Standard", + value: "__shipping_standard", + amount: 0, + formatted: "Free", + }, + { + label: "Express", + value: "__shipping_express", + amount: 1200, + formatted: "$12.00", + }, +]; + +export function useShippingMethod() { + const deliveryMethods = useQuery({ + queryKey: ["delivery-methods"], + queryFn: () => { + /** + * Replace these with your own delivery methods. You can also fetch them from the API. + */ + return staticDeliveryMethods; + }, + }); + + return { + ...deliveryMethods, + }; +} diff --git a/examples/global-services/src/app/(checkout)/layout.tsx b/examples/global-services/src/app/(checkout)/layout.tsx new file mode 100644 index 00000000..16f80170 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/layout.tsx @@ -0,0 +1,51 @@ +import { Inter } from "next/font/google"; +import { ReactNode } from "react"; +import { getStoreInitialState } from "../../lib/get-store-initial-state"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { Providers } from "../providers"; +import clsx from "clsx"; + +const { SITE_NAME } = process.env; +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +export const metadata = { + metadataBase: new URL(baseUrl), + title: { + default: SITE_NAME!, + template: `%s | ${SITE_NAME}`, + }, + robots: { + follow: true, + index: true, + }, +}; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export default async function CheckoutLayout({ + children, +}: { + children: ReactNode; +}) { + const client = getServerSideImplicitClient(); + const initialState = await getStoreInitialState(client); + + return ( + + + {/* headless ui needs this div - https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1745272229 */} +
+ +
{children}
+
+
+ + + ); +} diff --git a/examples/global-services/src/app/(checkout)/not-found.tsx b/examples/global-services/src/app/(checkout)/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/global-services/src/app/(checkout)/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
+ + 404 - The page could not be found. + + + Back to home + +
+ ); +} diff --git a/examples/global-services/src/app/(store)/about/page.tsx b/examples/global-services/src/app/(store)/about/page.tsx new file mode 100644 index 00000000..fc900ed0 --- /dev/null +++ b/examples/global-services/src/app/(store)/about/page.tsx @@ -0,0 +1,5 @@ +import Blurb from "../../../components/shared/blurb"; + +export default function About() { + return ; +} diff --git a/examples/global-services/src/app/(store)/account/AccountNavigation.tsx b/examples/global-services/src/app/(store)/account/AccountNavigation.tsx new file mode 100644 index 00000000..f3474312 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/AccountNavigation.tsx @@ -0,0 +1,54 @@ +"use client"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Button } from "../../../components/button/Button"; +import { logout } from "../../(auth)/actions"; +import { useTransition } from "react"; + +export function AccountNavigation() { + const pathname = usePathname(); + const [isPending, startTransition] = useTransition(); + + return ( + + ); +} diff --git a/examples/global-services/src/app/(store)/account/addresses/DeleteAddressBtn.tsx b/examples/global-services/src/app/(store)/account/addresses/DeleteAddressBtn.tsx new file mode 100644 index 00000000..2230af9d --- /dev/null +++ b/examples/global-services/src/app/(store)/account/addresses/DeleteAddressBtn.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { deleteAddress } from "./actions"; +import { FormStatusButton } from "../../../../components/button/FormStatusButton"; +import { TrashIcon } from "@heroicons/react/24/outline"; +import React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + accountAddressesQueryKeys, + useAuthedAccountMember, +} from "@elasticpath/react-shopper-hooks"; + +export function DeleteAddressBtn({ addressId }: { addressId: string }) { + const queryClient = useQueryClient(); + const { selectedAccountToken } = useAuthedAccountMember(); + + return ( +
{ + await deleteAddress(formData); + await queryClient.invalidateQueries({ + queryKey: [ + ...accountAddressesQueryKeys.list({ + accountId: selectedAccountToken?.account_id, + }), + ], + }); + }} + > + + } + > + Delete + +
+ ); +} diff --git a/examples/global-services/src/app/(store)/account/addresses/[addressId]/UpdateForm.tsx b/examples/global-services/src/app/(store)/account/addresses/[addressId]/UpdateForm.tsx new file mode 100644 index 00000000..a32c40e1 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/addresses/[addressId]/UpdateForm.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { updateAddress } from "../actions"; +import { Label } from "../../../../../components/label/Label"; +import { Input } from "../../../../../components/input/Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../../../components/select/Select"; +import { FormStatusButton } from "../../../../../components/button/FormStatusButton"; +import React from "react"; +import { countries as staticCountries } from "../../../../../lib/all-countries"; +import { AccountAddress } from "@elasticpath/js-sdk"; +import { + accountAddressesQueryKeys, + useAuthedAccountMember, +} from "@elasticpath/react-shopper-hooks"; +import { useQueryClient } from "@tanstack/react-query"; + +export function UpdateForm({ + addressId, + addressData, +}: { + addressId: string; + addressData: AccountAddress; +}) { + const queryClient = useQueryClient(); + const { selectedAccountToken } = useAuthedAccountMember(); + const countries = staticCountries; + + return ( +
{ + await updateAddress(formData); + await queryClient.invalidateQueries({ + queryKey: [ + ...accountAddressesQueryKeys.list({ + accountId: selectedAccountToken?.account_id, + }), + ], + }); + }} + className="flex flex-col gap-5" + > +
+ +
+

+ + +

+
+
+

+ + +

+

+ + +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + Save changes + +
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/account/addresses/[addressId]/page.tsx b/examples/global-services/src/app/(store)/account/addresses/[addressId]/page.tsx new file mode 100644 index 00000000..8dcd4d4f --- /dev/null +++ b/examples/global-services/src/app/(store)/account/addresses/[addressId]/page.tsx @@ -0,0 +1,64 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../../lib/cookie-constants"; +import { getServerSideImplicitClient } from "../../../../../lib/epcc-server-side-implicit-client"; +import { Button } from "../../../../../components/button/Button"; +import Link from "next/link"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import React from "react"; +import { Separator } from "../../../../../components/separator/Separator"; +import { UpdateForm } from "./UpdateForm"; + +export const dynamic = "force-dynamic"; + +export default async function Address({ + params, +}: { + params: { addressId: string }; +}) { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + const { addressId } = params; + + const activeAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + const address = await client.AccountAddresses.Get({ + account: activeAccount.account_id, + address: addressId, + token: activeAccount.token, + }); + + const addressData = address.data; + + return ( +
+
+ +
+ +
+

Edit Address

+ +
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/account/addresses/actions.ts b/examples/global-services/src/app/(store)/account/addresses/actions.ts new file mode 100644 index 00000000..a128dad5 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/addresses/actions.ts @@ -0,0 +1,200 @@ +"use server"; + +import { z } from "zod"; +import { cookies } from "next/headers"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { revalidatePath } from "next/cache"; +import { shippingAddressSchema } from "../../../../components/checkout/form-schema/checkout-form-schema"; +import { AccountAddress, Resource } from "@elasticpath/js-sdk"; +import { redirect } from "next/navigation"; + +const deleteAddressSchema = z.object({ + addressId: z.string(), +}); + +const updateAddressSchema = shippingAddressSchema.merge( + z.object({ + name: z.string(), + addressId: z.string(), + line_2: z + .string() + .optional() + .transform((e) => (e === "" ? undefined : e)), + }), +); + +const addAddressSchema = shippingAddressSchema.merge( + z.object({ + name: z.string(), + line_2: z + .string() + .optional() + .transform((e) => (e === "" ? undefined : e)), + }), +); + +export async function deleteAddress(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = deleteAddressSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + throw new Error("Invalid address id"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const { addressId } = validatedFormData.data; + const selectedAccount = getSelectedAccount(accountMemberCreds); + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/accounts/${selectedAccount.account_id}/addresses/${addressId}`, + "DELETE", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + revalidatePath("/accounts/addresses"); + } catch (error) { + console.error(error); + throw new Error("Error deleting address"); + } +} + +export async function updateAddress(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = updateAddressSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid address submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const selectedAccount = getSelectedAccount(accountMemberCreds); + + const { addressId, ...addressData } = validatedFormData.data; + + const body = { + data: { + type: "address", + id: addressId, + ...addressData, + }, + }; + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/accounts/${selectedAccount.account_id}/addresses/${addressId}`, + "PUT", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + revalidatePath("/accounts/addresses"); + } catch (error) { + console.error(error); + throw new Error("Error updating address"); + } +} + +export async function addAddress(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = addAddressSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid address submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const selectedAccount = getSelectedAccount(accountMemberCreds); + + const { ...addressData } = validatedFormData.data; + + const body = { + data: { + type: "address", + ...addressData, + }, + }; + + let redirectUrl: string | undefined = undefined; + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + const result = (await client.request.send( + `/accounts/${selectedAccount.account_id}/addresses`, + "POST", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + )) as Resource; + + redirectUrl = `/account/addresses/${result.data.id}`; + } catch (error) { + console.error(error); + throw new Error("Error adding address"); + } + + revalidatePath("/account/addresses"); + redirect(redirectUrl); +} diff --git a/examples/global-services/src/app/(store)/account/addresses/add/AddForm.tsx b/examples/global-services/src/app/(store)/account/addresses/add/AddForm.tsx new file mode 100644 index 00000000..08339c29 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/addresses/add/AddForm.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { addAddress } from "../actions"; +import { Label } from "../../../../../components/label/Label"; +import { Input } from "../../../../../components/input/Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../../../components/select/Select"; +import { FormStatusButton } from "../../../../../components/button/FormStatusButton"; +import React from "react"; +import { countries as staticCountries } from "../../../../../lib/all-countries"; +import { useQueryClient } from "@tanstack/react-query"; +import { + accountAddressesQueryKeys, + useAuthedAccountMember, +} from "@elasticpath/react-shopper-hooks"; + +export function AddForm() { + const queryClient = useQueryClient(); + const { selectedAccountToken } = useAuthedAccountMember(); + const countries = staticCountries; + + return ( +
{ + await addAddress(formData); + await queryClient.invalidateQueries({ + queryKey: [ + ...accountAddressesQueryKeys.list({ + accountId: selectedAccountToken?.account_id, + }), + ], + }); + }} + className="flex flex-col gap-5" + > +
+
+

+ + +

+
+
+

+ + +

+

+ + +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + Save changes + +
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/account/addresses/add/page.tsx b/examples/global-services/src/app/(store)/account/addresses/add/page.tsx new file mode 100644 index 00000000..479aed5b --- /dev/null +++ b/examples/global-services/src/app/(store)/account/addresses/add/page.tsx @@ -0,0 +1,43 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { retrieveAccountMemberCredentials } from "../../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../../lib/cookie-constants"; +import { Button } from "../../../../../components/button/Button"; +import Link from "next/link"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import React from "react"; +import { Separator } from "../../../../../components/separator/Separator"; +import { AddForm } from "./AddForm"; + +export const dynamic = "force-dynamic"; + +export default async function AddAddress() { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + return ( +
+
+ +
+ +
+

Add Address

+ +
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/account/addresses/page.tsx b/examples/global-services/src/app/(store)/account/addresses/page.tsx new file mode 100644 index 00000000..97e0ffa3 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/addresses/page.tsx @@ -0,0 +1,90 @@ +import { + PencilSquareIcon, + PlusIcon, +} from "@heroicons/react/24/outline"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { redirect } from "next/navigation"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import Link from "next/link"; +import { Button } from "../../../../components/button/Button"; +import { Separator } from "../../../../components/separator/Separator"; +import React from "react"; +import { DeleteAddressBtn } from "./DeleteAddressBtn"; + +export const dynamic = "force-dynamic"; + +export default async function Addresses() { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + const addresses = await client.AccountAddresses.All({ + account: selectedAccount.account_id, + token: selectedAccount.token, + }); + + return ( +
+
+

Your info

+
    + {addresses.data.map((address) => ( +
  • +
    +
    +

    + {address.name} +

    +
    +
    +

    + {address.first_name} {address.last_name} +

    +

    {address.line_1}

    +

    {address.postcode}

    +
    +
    +
    + + +
    +
  • + ))} +
+
+ +
+ +
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/account/breadcrumb.tsx b/examples/global-services/src/app/(store)/account/breadcrumb.tsx new file mode 100644 index 00000000..1ca7add5 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/breadcrumb.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { HomeIcon } from "@heroicons/react/20/solid"; +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import clsx from "clsx"; + +interface BreadcrumbItem { + label: string; + href?: string; + current: boolean; +} + +export function Breadcrumb() { + const pathname = usePathname(); + + const pathnameParts = pathname.split("/").filter(Boolean); + + // Function to convert pathname to breadcrumb items + const getBreadcrumbItems = (pathname: string): BreadcrumbItem[] => { + const pathSegments = pathname + .split("/") + .filter((segment) => segment !== ""); + + return pathSegments.map((segment, index) => { + const href = + segment === "account" + ? undefined + : `/${pathSegments.slice(0, index + 1).join("/")}`; + return { + label: segment, + href, + current: index === pathSegments.length - 1, // Set current to true for the last segment + }; + }); + }; + + const breadcrumbItems = getBreadcrumbItems(pathname); + + return ( + + ); +} diff --git a/examples/global-services/src/app/(store)/account/layout.tsx b/examples/global-services/src/app/(store)/account/layout.tsx new file mode 100644 index 00000000..2bc085fe --- /dev/null +++ b/examples/global-services/src/app/(store)/account/layout.tsx @@ -0,0 +1,17 @@ +import { ReactNode } from "react"; +import { AccountNavigation } from "./AccountNavigation"; + +export default function AccountLayout({ children }: { children: ReactNode }) { + return ( +
+
+
+
+ +
+
{children}
+
+
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/account/orders/OrderItem.tsx b/examples/global-services/src/app/(store)/account/orders/OrderItem.tsx new file mode 100644 index 00000000..52ff1f43 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/orders/OrderItem.tsx @@ -0,0 +1,70 @@ +import { ReactNode } from "react"; +import { Order, OrderItem as OrderItemType } from "@elasticpath/js-sdk"; +import { ProductThumbnail } from "./[orderId]/ProductThumbnail"; +import Link from "next/link"; +import { formatIsoDateString } from "../../../../lib/format-iso-date-string"; + +export type OrderItemProps = { + children?: ReactNode; + order: Order; + orderItems: OrderItemType[]; + imageUrl?: string; +}; + +export function OrderItem({ children, order, orderItems }: OrderItemProps) { + // Sorted order items are used to determine which image to show + // showing the most expensive item's image + const sortedOrderItems = orderItems.sort( + (a, b) => b.unit_price.amount - a.unit_price.amount, + ); + return ( +
+
+ + + +
+
+ + Order # {order.external_ref ?? order.id} + + +

+ {formatOrderItemsTitle(sortedOrderItems)} +

+ + {children} +
+
+ + {order.meta.display_price.with_tax.formatted} +
+
+ ); +} + +function formatOrderItemsTitle(orderItems: OrderItemType[]): string { + if (orderItems.length === 0) { + return "No items in the order"; + } + + if (orderItems.length === 1) { + return orderItems[0].name; + } + + const firstTwoItems = orderItems.slice(0, 2).map((item) => item.name); + const remainingItemCount = orderItems.length - 2; + + if (remainingItemCount === 0) { + return `${firstTwoItems.join(" and ")} in the order`; + } + + return `${firstTwoItems.join(", ")} and ${remainingItemCount} other item${ + remainingItemCount > 1 ? "s" : "" + }`; +} diff --git a/examples/global-services/src/app/(store)/account/orders/OrderItemWithDetails.tsx b/examples/global-services/src/app/(store)/account/orders/OrderItemWithDetails.tsx new file mode 100644 index 00000000..4781054b --- /dev/null +++ b/examples/global-services/src/app/(store)/account/orders/OrderItemWithDetails.tsx @@ -0,0 +1,32 @@ +import { OrderItem, OrderItemProps } from "./OrderItem"; +import { formatIsoDateString } from "../../../../lib/format-iso-date-string"; + +export function OrderItemWithDetails(props: Omit) { + const sortedOrderItems = props.orderItems.sort( + (a, b) => b.unit_price.amount - a.unit_price.amount, + ); + + return ( + +
+
    + {sortedOrderItems.map((item) => ( +
  • + {item.quantity} × {item.name} +
  • + ))} +
+
+
+ Ordered + +
+
+ + ); +} diff --git a/examples/global-services/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx b/examples/global-services/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx new file mode 100644 index 00000000..b5295d60 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx @@ -0,0 +1,42 @@ +import { ProductThumbnail } from "./ProductThumbnail"; +import { OrderItem } from "@elasticpath/js-sdk"; +import Link from "next/link"; + +export function OrderLineItem({ orderItem }: { orderItem: OrderItem }) { + return ( +
+
+ + + +
+
+
+ +

+ {orderItem.name} +

+ + + Quantity: {orderItem.quantity} + +
+
+ + {orderItem.meta?.display_price?.with_tax?.value.formatted} + + {orderItem.meta?.display_price?.without_discount?.value.amount && + orderItem.meta?.display_price?.without_discount?.value.amount !== + orderItem.meta?.display_price?.with_tax?.value.amount && ( + + { + orderItem.meta?.display_price?.without_discount?.value + .formatted + } + + )} +
+
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx b/examples/global-services/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx new file mode 100644 index 00000000..9e8f8d50 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx @@ -0,0 +1,22 @@ +"use client"; +import Image from "next/image"; +import { useProduct } from "@elasticpath/react-shopper-hooks"; + +const gray1pxBase64 = + "data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="; + +export function ProductThumbnail({ productId }: { productId: string }) { + const { data, included } = useProduct({ productId }); + + const imageHref = included?.main_images?.[0]?.link.href; + const title = data?.attributes?.name ?? "Loading..."; + return ( + {title} + ); +} diff --git a/examples/global-services/src/app/(store)/account/orders/[orderId]/page.tsx b/examples/global-services/src/app/(store)/account/orders/[orderId]/page.tsx new file mode 100644 index 00000000..92419c87 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/orders/[orderId]/page.tsx @@ -0,0 +1,206 @@ +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../../lib/cookie-constants"; +import { notFound, redirect } from "next/navigation"; +import { getServerSideImplicitClient } from "../../../../../lib/epcc-server-side-implicit-client"; +import { + Order as OrderType, + OrderIncluded, + OrderItem, + RelationshipToMany, +} from "@elasticpath/js-sdk"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../../lib/retrieve-account-member-credentials"; +import { Button } from "../../../../../components/button/Button"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; +import { formatIsoDateString } from "../../../../../lib/format-iso-date-string"; +import { OrderLineItem } from "./OrderLineItem"; + +export const dynamic = "force-dynamic"; + +export default async function Order({ + params, +}: { + params: { orderId: string }; +}) { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + let result: Awaited> | undefined = + undefined; + try { + result = await client.request.send( + `/orders/${params.orderId}?include=items`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + } catch (e: any) { + if ( + "errors" in e && + (e.errors as any)[0].detail === "The order does not exist" + ) { + notFound(); + } + throw e; + } + + const shopperOrder = result!.included + ? resolveShopperOrder(result!.data, result!.included) + : { raw: result!.data, items: [] }; + + const shippingAddress = shopperOrder.raw.shipping_address; + + const productItems = shopperOrder.items.filter( + (item) => + item.unit_price.amount >= 0 && !item.sku.startsWith("__shipping_"), + ); + const shippingItem = shopperOrder.items.find((item) => + item.sku.startsWith("__shipping_"), + ); + + return ( +
+
+ +
+
+
+
+

Order # {shopperOrder.raw.id}

+ +
+
+
+
+ Shipping address +

+ {shippingAddress.first_name} {shippingAddress.last_name} +
+ {shippingAddress.line_1} +
+ {shippingAddress.city ?? shippingAddress.county},{" "} + {shippingAddress.postcode} {shippingAddress.country} +

+
+
+ Shipping status + {shopperOrder.raw.shipping} +
+
+ Payment status + {shopperOrder.raw.payment} +
+
+
+
    + {productItems.map((item) => ( +
  • + +
  • + ))} +
+
+
+
+
+ Subtotal + + {shopperOrder.raw.meta.display_price.without_tax.formatted} + +
+
+ Shipping + + {shippingItem?.meta?.display_price?.with_tax?.value.formatted ?? + shopperOrder.raw.meta.display_price.shipping.formatted} + +
+ {shopperOrder.raw.meta.display_price.discount.amount < 0 && ( +
+ Discount + + {shopperOrder.raw.meta.display_price.discount.formatted} + +
+ )} +
+ Sales Tax + + {shopperOrder.raw.meta.display_price.tax.formatted} + +
+
+
+
+ Total + + {shopperOrder.raw.meta.display_price.with_tax.formatted} + +
+
+
+ ); +} + +function resolveOrderItemsFromRelationship( + itemRelationships: RelationshipToMany<"item">["data"], + itemMap: Record, +): OrderItem[] { + return itemRelationships.reduce((orderItems, itemRel) => { + const includedItem: OrderItem | undefined = itemMap[itemRel.id]; + return [...orderItems, ...(includedItem && [includedItem])]; + }, [] as OrderItem[]); +} + +function resolveShopperOrder( + order: OrderType, + included: NonNullable, +): { raw: OrderType; items: OrderItem[] } { + // Create a map of included items by their id + const itemMap = included.items + ? included.items.reduce( + (acc, item) => { + return { ...acc, [item.id]: item }; + }, + {} as Record, + ) + : {}; + + // Map the items in the data array to their corresponding included items + const orderItems = order.relationships?.items?.data + ? resolveOrderItemsFromRelationship(order.relationships.items.data, itemMap) + : []; + + return { + raw: order, + items: orderItems, + }; +} diff --git a/examples/global-services/src/app/(store)/account/orders/page.tsx b/examples/global-services/src/app/(store)/account/orders/page.tsx new file mode 100644 index 00000000..d6109361 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/orders/page.tsx @@ -0,0 +1,128 @@ +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { redirect } from "next/navigation"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { + Order, + OrderItem, + RelationshipToMany, + ResourcePage, +} from "@elasticpath/js-sdk"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { ResourcePagination } from "../../../../components/pagination/ResourcePagination"; +import { DEFAULT_PAGINATION_LIMIT } from "../../../../lib/constants"; +import { OrderItemWithDetails } from "./OrderItemWithDetails"; + +export const dynamic = "force-dynamic"; + +export default async function Orders({ + searchParams, +}: { + searchParams?: { + limit?: string; + offset?: string; + page?: string; + }; +}) { + const currentPage = Number(searchParams?.page) || 1; + const limit = Number(searchParams?.limit) || DEFAULT_PAGINATION_LIMIT; + const offset = Number(searchParams?.offset) || 0; + + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + const result: Awaited> = + await client.request.send( + `/orders?include=items&page[limit]=${limit}&page[offset]=${offset}`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + const mappedOrders = result.included + ? resolveShopperOrder(result.data, result.included) + : []; + + const totalPages = Math.ceil(result.meta.results.total / limit); + + return ( +
+
+

Order history

+
+
+
    + {mappedOrders.map(({ raw: order, items }) => ( +
  • + +
  • + ))} +
+
+
+ +
+
+ ); +} + +function resolveOrderItemsFromRelationship( + itemRelationships: RelationshipToMany<"item">["data"], + itemMap: Record, +): OrderItem[] { + return itemRelationships.reduce((orderItems, itemRel) => { + const includedItem: OrderItem | undefined = itemMap[itemRel.id]; + return [...orderItems, ...(includedItem && [includedItem])]; + }, [] as OrderItem[]); +} + +function resolveShopperOrder( + data: Order[], + included: NonNullable< + ResourcePage["included"] + >, +): { raw: Order; items: OrderItem[] }[] { + // Create a map of included items by their id + const itemMap = included.items.reduce( + (acc, item) => { + return { ...acc, [item.id]: item }; + }, + {} as Record, + ); + + // Map the items in the data array to their corresponding included items + return data.map((order) => { + const orderItems = order.relationships?.items?.data + ? resolveOrderItemsFromRelationship( + order.relationships.items.data, + itemMap, + ) + : []; + + return { + raw: order, + items: orderItems, + }; + }); +} diff --git a/examples/global-services/src/app/(store)/account/summary/YourInfoForm.tsx b/examples/global-services/src/app/(store)/account/summary/YourInfoForm.tsx new file mode 100644 index 00000000..9fb96377 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/summary/YourInfoForm.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { updateAccount } from "./actions"; +import { Label } from "../../../../components/label/Label"; +import { Input } from "../../../../components/input/Input"; +import { FormStatusButton } from "../../../../components/button/FormStatusButton"; +import { useState } from "react"; + +export function YourInfoForm({ + accountId, + defaultValues, +}: { + accountId: string; + defaultValues?: { name?: string; email?: string }; +}) { + const [error, setError] = useState(undefined); + + async function updateAccountAction(formData: FormData) { + const result = await updateAccount(formData); + + if (result && "error" in result) { + setError(result.error); + } + } + + return ( +
+
+ Your information +

+ + +

+

+ + +

+ {error && {error}} +
+
+ Save changes +
+ +
+ ); +} diff --git a/examples/global-services/src/app/(store)/account/summary/actions.ts b/examples/global-services/src/app/(store)/account/summary/actions.ts new file mode 100644 index 00000000..5431608d --- /dev/null +++ b/examples/global-services/src/app/(store)/account/summary/actions.ts @@ -0,0 +1,220 @@ +"use server"; + +import { z } from "zod"; +import { cookies } from "next/headers"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { revalidatePath } from "next/cache"; +import { getServerSideCredentialsClient } from "../../../../lib/epcc-server-side-credentials-client"; +import { getErrorMessage } from "../../../../lib/get-error-message"; + +const updateAccountSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +/** + * TODO request not working for implicit token + EP account management token + * @param formData + */ +export async function updateAccount(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = updateAccountSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid account submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const selectedAccount = getSelectedAccount(accountMemberCreds); + + const { name, id } = validatedFormData.data; + + const body = { + data: { + type: "account", + name, + legal_name: name, + }, + }; + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/accounts/${id}`, + "PUT", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + revalidatePath("/accounts/summary"); + } catch (error) { + console.error(getErrorMessage(error)); + return { + error: getErrorMessage(error), + }; + } + + return; +} + +const updateUserAuthenticationPasswordProfileSchema = z.object({ + username: z.string(), + currentPassword: z.string().optional(), + newPassword: z.string().optional(), +}); + +const PASSWORD_PROFILE_ID = process.env.NEXT_PUBLIC_PASSWORD_PROFILE_ID!; +const AUTHENTICATION_REALM_ID = + process.env.NEXT_PUBLIC_AUTHENTICATION_REALM_ID!; + +export async function updateUserAuthenticationPasswordProfile( + formData: FormData, +) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = + updateUserAuthenticationPasswordProfileSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const { username, newPassword, currentPassword } = validatedFormData.data; + + // Re auth the user to check the current password is correct + const reAuthResult = await client.AccountMembers.GenerateAccountToken({ + type: "account_management_authentication_token", + authentication_mechanism: "password", + password_profile_id: PASSWORD_PROFILE_ID, + username, + password: currentPassword, + }); + + const reAuthedSelectedAccount = reAuthResult.data.find( + (entry) => entry.account_id === accountMemberCreds.selected, + ); + + if (!reAuthedSelectedAccount) { + throw new Error("Error re-authenticating user"); + } + + const credsClient = getServerSideCredentialsClient(); + const userAuthenticationPasswordProfileInfoResult = + await credsClient.UserAuthenticationPasswordProfile.All( + AUTHENTICATION_REALM_ID, + accountMemberCreds.accountMemberId, + ); + + const userAuthenticationPasswordProfileInfo = + userAuthenticationPasswordProfileInfoResult.data.find( + (entry) => entry.password_profile_id === PASSWORD_PROFILE_ID, + ); + + if (!userAuthenticationPasswordProfileInfo) { + throw new Error( + "User authentication password profile info not found for password profile", + ); + } + + const body = { + data: { + type: "user_authentication_password_profile_info", + id: userAuthenticationPasswordProfileInfo.id, + password_profile_id: PASSWORD_PROFILE_ID, + ...(username && { username }), + ...(newPassword && { password: newPassword }), + }, + }; + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/authentication-realms/${AUTHENTICATION_REALM_ID}/user-authentication-info/${accountMemberCreds.accountMemberId}/user-authentication-password-profile-info/${userAuthenticationPasswordProfileInfo.id}`, + "PUT", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": + reAuthedSelectedAccount.token, + }, + ); + + revalidatePath("/accounts"); + } catch (error) { + console.error(error); + throw new Error("Error updating account"); + } +} + +// async function getOneTimePasswordToken( +// client: ElasticPath, +// username: string, +// ): Promise { +// const response = await client.OneTimePasswordTokenRequest.Create( +// AUTHENTICATION_REALM_ID, +// PASSWORD_PROFILE_ID, +// { +// type: "one_time_password_token_request", +// username, +// purpose: "reset_password", +// }, +// ); +// +// const result2 = await client.request.send( +// `/authentication-realms/${AUTHENTICATION_REALM_ID}/password-profiles/${PASSWORD_PROFILE_ID}/one-time-password-token-request`, +// "POST", +// { +// data: { +// type: "one_time_password_token_request", +// username, +// purpose: "reset_password", +// }, +// }, +// undefined, +// client, +// false, +// "v2", +// ); +// +// return response; +// } diff --git a/examples/global-services/src/app/(store)/account/summary/page.tsx b/examples/global-services/src/app/(store)/account/summary/page.tsx new file mode 100644 index 00000000..89825480 --- /dev/null +++ b/examples/global-services/src/app/(store)/account/summary/page.tsx @@ -0,0 +1,113 @@ +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { redirect } from "next/navigation"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { Label } from "../../../../components/label/Label"; +import { Input } from "../../../../components/input/Input"; +import { FormStatusButton } from "../../../../components/button/FormStatusButton"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { updateUserAuthenticationPasswordProfile } from "./actions"; +import { YourInfoForm } from "./YourInfoForm"; + +export const dynamic = "force-dynamic"; + +export default async function AccountSummary() { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const client = getServerSideImplicitClient(); + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const account: Awaited> = + await client.request.send( + `/accounts/${selectedAccount.account_id}`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + const accountMember: Awaited> = + await client.request.send( + `/account-members/${accountMemberCookie.accountMemberId}`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + return ( +
+
+

Your info

+ +
+
+

Change Password

+
+
+ Password information +

+ + +

+

+ + +

+

+ + +

+
+ +
+ + Save changes + +
+
+
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/cart/CartItem.tsx b/examples/global-services/src/app/(store)/cart/CartItem.tsx new file mode 100644 index 00000000..36b2cde6 --- /dev/null +++ b/examples/global-services/src/app/(store)/cart/CartItem.tsx @@ -0,0 +1,61 @@ +"use client"; +import { useCartRemoveItem } from "@elasticpath/react-shopper-hooks"; +import { ProductThumbnail } from "../account/orders/[orderId]/ProductThumbnail"; +import { NumberInput } from "../../../components/number-input/NumberInput"; +import Link from "next/link"; +import { CartItem as CartItemType } from "@elasticpath/js-sdk"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export type CartItemProps = { + item: CartItemType; +}; + +export function CartItem({ item }: CartItemProps) { + const { mutate, isPending } = useCartRemoveItem(); + + return ( +
+
+ +
+
+
+
+ + {item.name} + + + Quantity: {item.quantity} + +
+
+ + {item.meta.display_price.with_tax.value.formatted} + + {item.meta.display_price.without_discount?.value.amount && + item.meta.display_price.without_discount?.value.amount !== + item.meta.display_price.with_tax.value.amount && ( + + {item.meta.display_price.without_discount?.value.formatted} + + )} +
+
+
+ + {isPending ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/cart/CartItemWide.tsx b/examples/global-services/src/app/(store)/cart/CartItemWide.tsx new file mode 100644 index 00000000..b7421e3c --- /dev/null +++ b/examples/global-services/src/app/(store)/cart/CartItemWide.tsx @@ -0,0 +1,61 @@ +"use client"; +import { useCartRemoveItem } from "@elasticpath/react-shopper-hooks"; +import { ProductThumbnail } from "../account/orders/[orderId]/ProductThumbnail"; +import Link from "next/link"; +import { NumberInput } from "../../../components/number-input/NumberInput"; +import { CartItemProps } from "./CartItem"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export function CartItemWide({ item }: CartItemProps) { + const { mutate, isPending } = useCartRemoveItem(); + + return ( +
+ {/* Thumbnail */} +
+ +
+ {/* Details */} +
+
+
+ + + {item.name} + + + + Quantity: {item.quantity} + +
+
+ + {isPending ? ( + + ) : ( + + )} +
+
+
+
+ + {item.meta.display_price.with_tax.value.formatted} + + {item.meta.display_price.without_discount?.value.amount && + item.meta.display_price.without_discount?.value.amount !== + item.meta.display_price.with_tax.value.amount && ( + + {item.meta.display_price.without_discount?.value.formatted} + + )} +
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/cart/CartSidebar.tsx b/examples/global-services/src/app/(store)/cart/CartSidebar.tsx new file mode 100644 index 00000000..2b80fcad --- /dev/null +++ b/examples/global-services/src/app/(store)/cart/CartSidebar.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useCart } from "@elasticpath/react-shopper-hooks"; +import { Separator } from "../../../components/separator/Separator"; +import { CartDiscounts } from "../../../components/cart/CartDiscounts"; +import * as React from "react"; +import { + ItemSidebarPromotions, + ItemSidebarSumTotal, + ItemSidebarTotals, + ItemSidebarTotalsDiscount, + ItemSidebarTotalsSubTotal, + ItemSidebarTotalsTax, +} from "../../../components/checkout-sidebar/ItemSidebar"; + +export function CartSidebar() { + const { data } = useCart(); + + const state = data?.state; + + if (!state) { + return null; + } + + const { meta } = state; + + return ( +
+ + + + {/* Totals */} + + +
+ Shipping + Calculated at checkout +
+ + +
+ + {/* Sum Total */} + +
+ ); +} diff --git a/examples/global-services/src/app/(store)/cart/CartView.tsx b/examples/global-services/src/app/(store)/cart/CartView.tsx new file mode 100644 index 00000000..0e8b7159 --- /dev/null +++ b/examples/global-services/src/app/(store)/cart/CartView.tsx @@ -0,0 +1,55 @@ +"use client"; +import { YourBag } from "./YourBag"; +import { CartSidebar } from "./CartSidebar"; +import { Button } from "../../../components/button/Button"; +import Link from "next/link"; +import { LockClosedIcon } from "@heroicons/react/24/solid"; +import { useCart } from "@elasticpath/react-shopper-hooks"; + +export function CartView() { + const { data } = useCart(); + + const state = data?.state; + + return ( + <> + {state?.items.length && state.items.length > 0 ? ( +
+ {/* Main Content */} +
+
+

Your Bag

+ {/* Cart Items */} + +
+
+ {/* Sidebar */} +
+ + +
+
+ ) : ( + <> + {/* Empty Cart */} +
+

+ Empty Cart +

+

Your cart is empty

+
+ +
+
+ + )} + + ); +} diff --git a/examples/global-services/src/app/(store)/cart/YourBag.tsx b/examples/global-services/src/app/(store)/cart/YourBag.tsx new file mode 100644 index 00000000..bda5c894 --- /dev/null +++ b/examples/global-services/src/app/(store)/cart/YourBag.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { CartItemWide } from "./CartItemWide"; +import { useCart } from "@elasticpath/react-shopper-hooks"; + +export function YourBag() { + const { data } = useCart(); + + const state = data?.state; + + return ( +
    + {state?.items.map((item) => { + return ( +
  • + +
  • + ); + })} +
+ ); +} diff --git a/examples/global-services/src/app/(store)/cart/page.tsx b/examples/global-services/src/app/(store)/cart/page.tsx new file mode 100644 index 00000000..de5e40bc --- /dev/null +++ b/examples/global-services/src/app/(store)/cart/page.tsx @@ -0,0 +1,5 @@ +import { CartView } from "./CartView"; + +export default async function CartPage() { + return ; +} diff --git a/examples/global-services/src/app/(store)/faq/page.tsx b/examples/global-services/src/app/(store)/faq/page.tsx new file mode 100644 index 00000000..786734bc --- /dev/null +++ b/examples/global-services/src/app/(store)/faq/page.tsx @@ -0,0 +1,5 @@ +import Blurb from "../../../components/shared/blurb"; + +export default function FAQ() { + return ; +} diff --git a/examples/global-services/src/app/(store)/layout.tsx b/examples/global-services/src/app/(store)/layout.tsx new file mode 100644 index 00000000..26377c29 --- /dev/null +++ b/examples/global-services/src/app/(store)/layout.tsx @@ -0,0 +1,64 @@ +import { ReactNode, Suspense } from "react"; +import { Inter } from "next/font/google"; +import { getStoreInitialState } from "../../lib/get-store-initial-state"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { Providers } from "../providers"; +import Header from "../../components/header/Header"; +import { Toaster } from "../../components/toast/toaster"; +import Footer from "../../components/footer/Footer"; + +const { SITE_NAME } = process.env; +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +export const metadata = { + metadataBase: new URL(baseUrl), + title: { + default: SITE_NAME!, + template: `%s | ${SITE_NAME}`, + }, + robots: { + follow: true, + index: true, + }, +}; + +/** + * Used to revalidate until the js-sdk supports passing of fetch options. + * At that point we can be more intelligent about revalidation. + */ +export const revalidate = 300; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export default async function StoreLayout({ + children, +}: { + children: ReactNode; +}) { + const client = getServerSideImplicitClient(); + const initialState = await getStoreInitialState(client); + + return ( + + + {/* headless ui needs this div - https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1745272229 */} +
+ +
+ + +
{children}
+
+
+ +
+ + + ); +} diff --git a/examples/global-services/src/app/(store)/not-found.tsx b/examples/global-services/src/app/(store)/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/global-services/src/app/(store)/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
+ + 404 - The page could not be found. + + + Back to home + +
+ ); +} diff --git a/examples/global-services/src/app/(store)/page.tsx b/examples/global-services/src/app/(store)/page.tsx new file mode 100644 index 00000000..6f87cd28 --- /dev/null +++ b/examples/global-services/src/app/(store)/page.tsx @@ -0,0 +1,38 @@ +import PromotionBanner from "../../components/promotion-banner/PromotionBanner"; +import FeaturedProducts from "../../components/featured-products/FeaturedProducts"; +import { Suspense } from "react"; + +export default async function Home() { + const promotion = { + title: "Your Elastic Path storefront", + description: + "This marks the beginning, embark on the journey of crafting something truly extraordinary, uniquely yours.", + }; + + return ( +
+ +
+
+
+ + + +
+
+
+
+ ); +} diff --git a/examples/global-services/src/app/(store)/products/[productId]/page.tsx b/examples/global-services/src/app/(store)/products/[productId]/page.tsx new file mode 100644 index 00000000..5de880de --- /dev/null +++ b/examples/global-services/src/app/(store)/products/[productId]/page.tsx @@ -0,0 +1,54 @@ +import { Metadata } from "next"; +import { ProductDetailsComponent, ProductProvider } from "./product-display"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { getProductById } from "../../../../services/products"; +import { notFound } from "next/navigation"; +import { parseProductResponse } from "@elasticpath/react-shopper-hooks"; +import React from "react"; + + import { RecommendedProducts } from "../../../../components/recommendations/RecommendationProducts"; + +export const dynamic = "force-dynamic"; + +type Props = { + params: { productId: string }; +}; + +export async function generateMetadata({ + params: { productId }, +}: Props): Promise { + const client = getServerSideImplicitClient(); + const product = await getProductById(productId, client); + + if (!product) { + notFound(); + } + + return { + title: product.data.attributes.name, + description: product.data.attributes.description, + }; +} + +export default async function ProductPage({ params }: Props) { + const client = getServerSideImplicitClient(); + const product = await getProductById(params.productId, client); + + if (!product) { + notFound(); + } + + const shopperProduct = await parseProductResponse(product, client); + + return ( +
+ + + + +
+ ); +} diff --git a/examples/global-services/src/app/(store)/products/[productId]/product-display.tsx b/examples/global-services/src/app/(store)/products/[productId]/product-display.tsx new file mode 100644 index 00000000..98ab9c15 --- /dev/null +++ b/examples/global-services/src/app/(store)/products/[productId]/product-display.tsx @@ -0,0 +1,49 @@ +"use client"; +import React, { ReactElement, ReactNode, useState } from "react"; +import { ShopperProduct } from "@elasticpath/react-shopper-hooks"; +import { VariationProductDetail } from "../../../../components/product/variations/VariationProduct"; +import BundleProductDetail from "../../../../components/product/bundles/BundleProduct"; +import { ProductContext } from "../../../../lib/product-context"; +import SimpleProductDetail from "../../../../components/product/SimpleProduct"; + +export function ProductProvider({ + children, +}: { + children: ReactNode; +}): ReactElement { + const [isChangingSku, setIsChangingSku] = useState(false); + + return ( + + {children} + + ); +} + +export function resolveProductDetailComponent( + product: ShopperProduct, +): JSX.Element { + switch (product.kind) { + case "base-product": + return ; + case "child-product": + return ; + case "simple-product": + return ; + case "bundle-product": + return ; + } +} + +export function ProductDetailsComponent({ + product, +}: { + product: ShopperProduct; +}) { + return resolveProductDetailComponent(product); +} diff --git a/examples/global-services/src/app/(store)/search/[[...node]]/layout.tsx b/examples/global-services/src/app/(store)/search/[[...node]]/layout.tsx new file mode 100644 index 00000000..937b6e50 --- /dev/null +++ b/examples/global-services/src/app/(store)/search/[[...node]]/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from "react"; +import Breadcrumb from "../../../../components/breadcrumb"; + +export default function SearchLayout({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/examples/global-services/src/app/(store)/search/[[...node]]/page.tsx b/examples/global-services/src/app/(store)/search/[[...node]]/page.tsx new file mode 100644 index 00000000..6e4b06bd --- /dev/null +++ b/examples/global-services/src/app/(store)/search/[[...node]]/page.tsx @@ -0,0 +1,11 @@ +import SearchResults from "../../../../components/search/SearchResults"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Search", + description: "Search for products", +}; + +export default async function SearchPage() { + return ; +} diff --git a/examples/global-services/src/app/(store)/shipping/page.tsx b/examples/global-services/src/app/(store)/shipping/page.tsx new file mode 100644 index 00000000..d5ee20b4 --- /dev/null +++ b/examples/global-services/src/app/(store)/shipping/page.tsx @@ -0,0 +1,5 @@ +import Blurb from "../../../components/shared/blurb"; + +export default function Shipping() { + return ; +} diff --git a/examples/global-services/src/app/(store)/terms/page.tsx b/examples/global-services/src/app/(store)/terms/page.tsx new file mode 100644 index 00000000..3b651abc --- /dev/null +++ b/examples/global-services/src/app/(store)/terms/page.tsx @@ -0,0 +1,5 @@ +import Blurb from "../../../components/shared/blurb"; + +export default function Terms() { + return ; +} diff --git a/examples/global-services/src/app/configuration-error/page.tsx b/examples/global-services/src/app/configuration-error/page.tsx new file mode 100644 index 00000000..5b9a5dba --- /dev/null +++ b/examples/global-services/src/app/configuration-error/page.tsx @@ -0,0 +1,69 @@ +import Link from "next/link"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Configuration Error", + description: "Configuration error page", +}; + +type Props = { + searchParams: { [key: string]: string | string[] | undefined }; +}; + +export default function ConfigurationErrorPage({ searchParams }: Props) { + const { + "missing-env-variable": missingEnvVariables, + authentication, + from, + } = searchParams; + + const issues: { [key: string]: string | string[] } = { + ...(missingEnvVariables && { missingEnvVariables }), + ...(authentication && { authentication }), + }; + const fromProcessed = Array.isArray(from) ? from[0] : from; + + return ( +
+ + There is a problem with the stores setup + + + Refresh + + + + + + + + + + {issues && + Object.keys(issues).map((key) => { + const issue = issues[key]; + return ( + + + + + ); + })} + +
IssueDetails
{key} +
    + {(Array.isArray(issue) ? issue : [issue]).map( + (message) => ( +
  • + {decodeURIComponent(message)} +
  • + ), + )} +
+
+
+ ); +} diff --git a/examples/global-services/src/app/error.tsx b/examples/global-services/src/app/error.tsx new file mode 100644 index 00000000..f4724026 --- /dev/null +++ b/examples/global-services/src/app/error.tsx @@ -0,0 +1,31 @@ +"use client"; +import Link from "next/link"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + +
+ + {error.digest} - Internal server error. + + + Back to home + + +
+ + + ); +} diff --git a/examples/global-services/src/app/layout.tsx b/examples/global-services/src/app/layout.tsx new file mode 100644 index 00000000..d87bb7de --- /dev/null +++ b/examples/global-services/src/app/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import "../styles/globals.css"; + +export default async function RootLayout({ + children, +}: { + children: ReactNode; +}) { + return <>{children}; +} diff --git a/examples/global-services/src/app/not-found.tsx b/examples/global-services/src/app/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/global-services/src/app/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
+ + 404 - The page could not be found. + + + Back to home + +
+ ); +} diff --git a/examples/global-services/src/app/providers.tsx b/examples/global-services/src/app/providers.tsx new file mode 100644 index 00000000..acb2a6ba --- /dev/null +++ b/examples/global-services/src/app/providers.tsx @@ -0,0 +1,44 @@ +"use client"; +import { ReactNode } from "react"; +import { + AccountProvider, + StoreProvider, + ElasticPathProvider, + InitialState, +} from "@elasticpath/react-shopper-hooks"; +import { QueryClient } from "@tanstack/react-query"; +import { getEpccImplicitClient } from "../lib/epcc-implicit-client"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../lib/cookie-constants"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 60 * 24, + retry: 1, + }, + }, +}); + +export function Providers({ + children, + initialState, +}: { + children: ReactNode; + initialState: InitialState; +}) { + const client = getEpccImplicitClient(); + + return ( + + + + {children} + + + + ); +} diff --git a/examples/global-services/src/components/Checkbox.tsx b/examples/global-services/src/components/Checkbox.tsx new file mode 100644 index 00000000..290cf273 --- /dev/null +++ b/examples/global-services/src/components/Checkbox.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { cn } from "../lib/cn"; +import { CheckIcon } from "@heroicons/react/24/solid"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/examples/global-services/src/components/LoadingDots.tsx b/examples/global-services/src/components/LoadingDots.tsx new file mode 100644 index 00000000..5ac56f3d --- /dev/null +++ b/examples/global-services/src/components/LoadingDots.tsx @@ -0,0 +1,13 @@ +import { cn } from "../lib/cn"; + +const dots = "mx-[1px] inline-block h-1 w-1 animate-blink rounded-md"; + +export function LoadingDots({ className }: { className: string }) { + return ( + + + + + + ); +} diff --git a/examples/global-services/src/components/NoImage.tsx b/examples/global-services/src/components/NoImage.tsx new file mode 100644 index 00000000..bf84f253 --- /dev/null +++ b/examples/global-services/src/components/NoImage.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { EyeSlashIcon } from "@heroicons/react/24/solid"; + +export const NoImage = (): JSX.Element => { + return ( +
+ +
+ ); +}; + +export default NoImage; diff --git a/examples/global-services/src/components/Spinner.tsx b/examples/global-services/src/components/Spinner.tsx new file mode 100644 index 00000000..9bd90a3e --- /dev/null +++ b/examples/global-services/src/components/Spinner.tsx @@ -0,0 +1,30 @@ +interface IProps { + width: string; + height: string; + absolute: boolean; +} + +const Spinner = (props: IProps) => { + return ( + + ); +}; + +export default Spinner; diff --git a/examples/global-services/src/components/accordion/Accordion.tsx b/examples/global-services/src/components/accordion/Accordion.tsx new file mode 100644 index 00000000..6f4f7aef --- /dev/null +++ b/examples/global-services/src/components/accordion/Accordion.tsx @@ -0,0 +1,51 @@ +"use client"; + +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { cn } from "../../lib/cn"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/examples/global-services/src/components/alert/Alert.tsx b/examples/global-services/src/components/alert/Alert.tsx new file mode 100644 index 00000000..eb8d9cd4 --- /dev/null +++ b/examples/global-services/src/components/alert/Alert.tsx @@ -0,0 +1,59 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +import { forwardRef, HTMLAttributes } from "react"; +import { cn } from "../../lib/cn"; + +const alertVariants = cva( + "relative w-full rounded-lg border border-black/60 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-black", + { + variants: { + variant: { + default: "bg-white text-black", + destructive: + "border-red-600/50 text-red-600 dark:border-red-600 [&>svg]:text-red-600", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = forwardRef< + HTMLDivElement, + HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = forwardRef< + HTMLParagraphElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = forwardRef< + HTMLParagraphElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/examples/global-services/src/components/breadcrumb.tsx b/examples/global-services/src/components/breadcrumb.tsx new file mode 100644 index 00000000..950c0130 --- /dev/null +++ b/examples/global-services/src/components/breadcrumb.tsx @@ -0,0 +1,36 @@ +"use client"; +import { createBreadcrumb } from "../lib/create-breadcrumb"; +import Link from "next/link"; +import { useStore } from "@elasticpath/react-shopper-hooks"; +import { buildBreadcrumbLookup } from "../lib/build-breadcrumb-lookup"; +import { usePathname } from "next/navigation"; + +export default function Breadcrumb(): JSX.Element { + const { nav } = useStore(); + const pathname = usePathname(); + const lookup = buildBreadcrumbLookup(nav ?? []); + const nodes = pathname.replace("/search/", "")?.split("/"); + const crumbs = createBreadcrumb(nodes, lookup); + + return ( +
    + {crumbs.length > 1 && + crumbs.map((entry, index, array) => ( +
  1. + {array.length === index + 1 ? ( + {entry.label} + ) : ( + + {entry.label} + + )} + {array.length !== index + 1 && /} +
  2. + ))} +
+ ); +} diff --git a/examples/global-services/src/components/button/Button.tsx b/examples/global-services/src/components/button/Button.tsx new file mode 100644 index 00000000..6d277a4e --- /dev/null +++ b/examples/global-services/src/components/button/Button.tsx @@ -0,0 +1,68 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../../lib/cn"; +import { ButtonHTMLAttributes, forwardRef } from "react"; +import { Slot } from "@radix-ui/react-slot"; + +const buttonVariants = cva( + "inline-flex items-center justify-center hover:opacity-90 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: "bg-black text-white", + secondary: "bg-transparent ring-2 ring-inset ring-black text-black", + ghost: "bg-brand-gray/10 text-black/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "px-8 py-3 text-lg", + medium: "px-6 py-2 text-base", + small: "px-3.5 py-1.5 text-sm", + icon: "w-10", + }, + reversed: { + true: "", + false: "", + }, + }, + compoundVariants: [ + { + variant: "primary", + reversed: true, + className: "bg-white text-black", + }, + { + variant: "secondary", + reversed: true, + className: "ring-white text-white", + }, + ], + defaultVariants: { + variant: "primary", + size: "default", + reversed: false, + }, + }, +); + +export interface ButtonProps + extends ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = forwardRef( + ({ className, variant, asChild = false, size, reversed, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/examples/global-services/src/components/button/FormStatusButton.tsx b/examples/global-services/src/components/button/FormStatusButton.tsx new file mode 100644 index 00000000..2b46a37e --- /dev/null +++ b/examples/global-services/src/components/button/FormStatusButton.tsx @@ -0,0 +1,41 @@ +"use client"; +import { cn } from "../../lib/cn"; +import { forwardRef, ReactNode } from "react"; +import { Button, ButtonProps } from "./Button"; +import LoaderIcon from "./LoaderIcon"; +import { useFormStatus } from "react-dom"; +import * as React from "react"; + +export interface FormStatusButtonProps extends ButtonProps { + status?: "loading" | "success" | "error" | "idle"; + icon?: ReactNode; +} + +const FormStatusButton = forwardRef( + ({ children, icon, status = "idle", className, ...props }, ref) => { + const { pending } = useFormStatus(); + return ( + + ); + }, +); +FormStatusButton.displayName = "FormStatusButton"; + +export { FormStatusButton }; diff --git a/examples/global-services/src/components/button/LoaderIcon.tsx b/examples/global-services/src/components/button/LoaderIcon.tsx new file mode 100644 index 00000000..92aa3952 --- /dev/null +++ b/examples/global-services/src/components/button/LoaderIcon.tsx @@ -0,0 +1,17 @@ +import { forwardRef, Ref, SVGProps } from "react"; + +const LoaderIcon = ( + props: SVGProps, + ref: Ref, +) => ( + + + +); +const ForwardRef = forwardRef(LoaderIcon); +export default ForwardRef; diff --git a/examples/global-services/src/components/button/StatusButton.tsx b/examples/global-services/src/components/button/StatusButton.tsx new file mode 100644 index 00000000..12ee784a --- /dev/null +++ b/examples/global-services/src/components/button/StatusButton.tsx @@ -0,0 +1,47 @@ +import { cn } from "../../lib/cn"; +import { forwardRef } from "react"; +import { Button, ButtonProps } from "./Button"; +import { CheckIcon } from "@heroicons/react/24/outline"; +import { XMarkIcon } from "@heroicons/react/24/solid"; +import LoaderIcon from "./LoaderIcon"; + +export interface StatusButtonProps extends ButtonProps { + status?: "loading" | "success" | "error" | "idle"; +} + +const StatusButton = forwardRef( + ({ children, status = "idle", className, ...props }, ref) => { + const Icon = + status === "loading" + ? LoaderIcon + : status === "success" + ? CheckIcon + : status === "error" + ? XMarkIcon + : null; + + return ( + + ); + }, +); +StatusButton.displayName = "StatusButton"; + +export { StatusButton }; diff --git a/examples/global-services/src/components/button/TextButton.tsx b/examples/global-services/src/components/button/TextButton.tsx new file mode 100644 index 00000000..e48149af --- /dev/null +++ b/examples/global-services/src/components/button/TextButton.tsx @@ -0,0 +1,41 @@ +import { cva, VariantProps } from "class-variance-authority"; +import { ButtonHTMLAttributes, forwardRef } from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "../../lib/cn"; + +const textButtonVariants = cva( + "font-medium text-black flex items-center gap-2 shrink-0", + { + variants: { + size: { + default: "text-lg", + small: "text-base", + }, + }, + defaultVariants: { + size: "default", + }, + }, +); + +export interface TextButtonProps + extends ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const TextButton = forwardRef( + ({ className, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +TextButton.displayName = "TextButton"; + +export { TextButton, textButtonVariants }; diff --git a/examples/global-services/src/components/cart/CartDiscounts.tsx b/examples/global-services/src/components/cart/CartDiscounts.tsx new file mode 100644 index 00000000..d6090d4e --- /dev/null +++ b/examples/global-services/src/components/cart/CartDiscounts.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { forwardRef, Fragment, HTMLAttributes } from "react"; +import { Separator } from "../separator/Separator"; +import { + PromotionCartItem, + useCartRemoveItem, +} from "@elasticpath/react-shopper-hooks"; +import { LoadingDots } from "../LoadingDots"; +import { XMarkIcon } from "@heroicons/react/24/solid"; +import * as React from "react"; +import { cn } from "../../lib/cn"; + +export function CartDiscounts({ + promotions, +}: { + promotions: PromotionCartItem[]; +}) { + const { mutate, isPending } = useCartRemoveItem(); + + return ( + promotions && + promotions.length > 0 && + promotions.map((promotion) => { + return ( + + + + {promotion.name} + + + + ); + }) + ); +} + +export function CartDiscountsReadOnly({ + promotions, +}: { + promotions: PromotionCartItem[]; +}) { + return ( + promotions && + promotions.length > 0 && + promotions.map((promotion) => { + return ( + + + {promotion.name} + + + + ); + }) + ); +} + +const CartDiscountItem = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, children, ...props }, ref) => { + return ( +
+
{children}
+
+ ); +}); +CartDiscountItem.displayName = "CartDiscountItem"; + +const CartDiscountName = forwardRef< + HTMLSpanElement, + HTMLAttributes +>(({ className, children, ...props }, ref) => { + return ( + + {children} + + ); +}); +CartDiscountName.displayName = "CartDiscountName"; diff --git a/examples/global-services/src/components/cart/CartSheet.tsx b/examples/global-services/src/components/cart/CartSheet.tsx new file mode 100644 index 00000000..75f36349 --- /dev/null +++ b/examples/global-services/src/components/cart/CartSheet.tsx @@ -0,0 +1,168 @@ +"use client"; +import { + Sheet, + SheetClose, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "../sheet/Sheet"; +import { Button } from "../button/Button"; +import { LockClosedIcon, XMarkIcon } from "@heroicons/react/24/solid"; +import { CartItem } from "../../app/(store)/cart/CartItem"; +import { useCart, useCartRemoveItem } from "@elasticpath/react-shopper-hooks"; +import { Separator } from "../separator/Separator"; +import { ShoppingBagIcon } from "@heroicons/react/24/outline"; +import { Fragment } from "react"; +import { AddPromotion } from "../checkout-sidebar/AddPromotion"; +import Link from "next/link"; +import { LoadingDots } from "../LoadingDots"; + +export function Cart() { + const { data } = useCart(); + + const state = data?.state; + + const { items, __extended } = state ?? {}; + + const { mutate, isPending } = useCartRemoveItem(); + + const discountedValues = ( + state?.meta?.display_price as + | { discount: { amount: number; formatted: string } } + | undefined + )?.discount; + + return ( + + + + + + +
+ + Your Bag + + + + Close + +
+ {items && items.length > 0 ? ( + <> + {/* Items */} +
+
    + {items.map((item) => { + return ( + +
  • + +
  • + +
    + ); + })} +
+
+ {/* Bottom */} + +
+ +
+ {__extended && + __extended.groupedItems.promotion.length > 0 && + __extended.groupedItems.promotion.map((promotion) => { + return ( + + +
+
+ + {promotion.name} +
+
+
+ ); + })} + + {/* Totals */} +
+
+ Sub Total + + {state?.meta?.display_price?.without_tax?.formatted} + +
+ {discountedValues && discountedValues.amount !== 0 && ( +
+ Discount + + {discountedValues.formatted} + +
+ )} +
+ + + + + + + +
+ + ) : ( +
+ +

Your bag is empty.

+
+ )} +
+
+ ); +} diff --git a/examples/global-services/src/components/checkout-item/CheckoutItem.tsx b/examples/global-services/src/components/checkout-item/CheckoutItem.tsx new file mode 100644 index 00000000..0bb311bf --- /dev/null +++ b/examples/global-services/src/components/checkout-item/CheckoutItem.tsx @@ -0,0 +1,32 @@ +"use client"; +import { ProductThumbnail } from "../../app/(store)/account/orders/[orderId]/ProductThumbnail"; +import Link from "next/link"; +import { CartItem } from "@elasticpath/js-sdk"; + +export function CheckoutItem({ item }: { item: CartItem }) { + return ( +
+
+ +
+
+ + {item.name} + + Quantity: {item.quantity} +
+
+ + {item.meta.display_price.with_tax.value.formatted} + + {item.meta.display_price.without_discount?.value.amount && + item.meta.display_price.without_discount?.value.amount !== + item.meta.display_price.with_tax.value.amount && ( + + {item.meta.display_price.without_discount?.value.formatted} + + )} +
+
+ ); +} diff --git a/examples/global-services/src/components/checkout-sidebar/AddPromotion.tsx b/examples/global-services/src/components/checkout-sidebar/AddPromotion.tsx new file mode 100644 index 00000000..2b6ad7bb --- /dev/null +++ b/examples/global-services/src/components/checkout-sidebar/AddPromotion.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { TextButton } from "../button/TextButton"; +import { PlusIcon } from "@heroicons/react/24/outline"; +import { Input } from "../input/Input"; +import { Button } from "../button/Button"; +import { applyDiscount } from "./actions"; +import { useQueryClient } from "@tanstack/react-query"; +import { cartQueryKeys, useCart } from "@elasticpath/react-shopper-hooks"; +import { useFormStatus } from "react-dom"; +import { LoadingDots } from "../LoadingDots"; + +export function AddPromotion() { + const [showInput, setShowInput] = useState(false); + const queryClient = useQueryClient(); + const { data } = useCart(); + const [error, setError] = useState(undefined); + + async function clientAction(formData: FormData) { + setError(undefined); + + const result = await applyDiscount(formData); + + setError(result.error); + + data?.cartId && + (await queryClient.invalidateQueries({ + queryKey: cartQueryKeys.detail(data.cartId), + })); + + !result.error && setShowInput(false); + } + + return showInput ? ( +
+
+ + + + {error &&

{error}

} +
+ ) : ( + setShowInput(true)}> + Add discount code + + ); +} + +function ApplyButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} diff --git a/examples/global-services/src/components/checkout-sidebar/ItemSidebar.tsx b/examples/global-services/src/components/checkout-sidebar/ItemSidebar.tsx new file mode 100644 index 00000000..8e1b174f --- /dev/null +++ b/examples/global-services/src/components/checkout-sidebar/ItemSidebar.tsx @@ -0,0 +1,166 @@ +"use client"; +import { Separator } from "../separator/Separator"; +import { AddPromotion } from "./AddPromotion"; +import { CheckoutItem } from "../checkout-item/CheckoutItem"; +import { CartState } from "@elasticpath/react-shopper-hooks"; +import { + Accordion, + AccordionContent, + AccordionTrigger, + AccordionItem, +} from "../accordion/Accordion"; +import { ShoppingBagIcon } from "@heroicons/react/24/outline"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import * as React from "react"; +import { Currency } from "@elasticpath/js-sdk"; +import { formatCurrency } from "../../lib/format-currency"; + +export function ItemSidebarItems({ items }: { items: CartState["items"] }) { + return ( + <> + {items && items.length > 0 && ( + <> + {items?.map((item) => )} + + + )} + + ); +} + +export function ItemSidebarPromotions() { + return ( +
+ +
+ ); +} + +export function ItemSidebarSumTotal({ meta }: { meta: CartState["meta"] }) { + return ( +
+ Total +
+ {meta?.display_price?.with_tax?.currency} + + {meta?.display_price?.with_tax?.formatted} + +
+
+ ); +} + +export function ItemSidebarTotals({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} + +export function ItemSidebarTotalsSubTotal({ + meta, +}: { + meta: CartState["meta"]; +}) { + return ( + + ); +} + +export function ItemSidebarTotalsDiscount({ + meta, +}: { + meta: CartState["meta"]; +}) { + const discountedValues = ( + meta?.display_price as + | { discount: { amount: number; formatted: string } } + | undefined + )?.discount; + + return ( + discountedValues && + discountedValues.amount !== 0 && ( +
+ Discount + + {discountedValues?.formatted ?? ""} + +
+ ) + ); +} + +export function ItemSidebarTotalsTax({ meta }: { meta: CartState["meta"] }) { + return ( + + ); +} + +export function ItemSidebarTotalsItem({ + label, + description, +}: { + label: string; + description: string; +}) { + return ( +
+ {label} + {description} +
+ ); +} + +export function ItemSidebarHideable({ + children, + meta, +}: { + meta: CartState["meta"]; + children: React.ReactNode; +}) { + return ( + <> + + + + + Hide order summary + + + {meta?.display_price?.with_tax?.formatted} + + + {children} + + +
{children}
+ + ); +} + +export function resolveTotalInclShipping( + shippingAmount: number, + totalAmount: number, + storeCurrency: Currency, +): string | undefined { + return formatCurrency(shippingAmount + totalAmount, storeCurrency); +} diff --git a/examples/global-services/src/components/checkout-sidebar/actions.ts b/examples/global-services/src/components/checkout-sidebar/actions.ts new file mode 100644 index 00000000..e2d283b8 --- /dev/null +++ b/examples/global-services/src/components/checkout-sidebar/actions.ts @@ -0,0 +1,43 @@ +"use server"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { cookies } from "next/headers"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { COOKIE_PREFIX_KEY } from "../../lib/resolve-cart-env"; +import { getErrorMessage } from "../../lib/get-error-message"; + +const applyDiscountSchema = z.object({ + code: z.string(), +}); + +export async function applyDiscount(formData: FormData) { + const client = getServerSideImplicitClient(); + + const cartCookie = cookies().get(`${COOKIE_PREFIX_KEY}_ep_cart`)?.value; + + if (!cartCookie) { + throw new Error("Cart cookie not found"); + } + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = applyDiscountSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + return { + error: "Invalid code", + }; + } + + try { + await client.Cart(cartCookie).AddPromotion(validatedFormData.data.code); + revalidatePath("/cart"); + } catch (error) { + console.error(error); + return { + error: getErrorMessage(error), + }; + } + + return { success: true }; +} diff --git a/examples/global-services/src/components/checkout/form-schema/checkout-form-schema.ts b/examples/global-services/src/components/checkout/form-schema/checkout-form-schema.ts new file mode 100644 index 00000000..5c987167 --- /dev/null +++ b/examples/global-services/src/components/checkout/form-schema/checkout-form-schema.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +/** + * Validating optional text input field https://github.com/colinhacks/zod/issues/310 + */ +const emptyStringToUndefined = z.literal("").transform(() => undefined); + +const guestInformationSchema = z.object({ + email: z.string({ required_error: "Required" }).email("Invalid email"), +}); + +const accountMemberInformationSchema = z.object({ + email: z.string({ required_error: "Required" }).email("Invalid email"), + name: z.string({ required_error: "Required" }), +}); + +const billingAddressSchema = z.object({ + first_name: z + .string({ required_error: "You need to provided a first name." }) + .min(2), + last_name: z + .string({ required_error: "You need to provided a last name." }) + .min(2), + company_name: z.string().min(1).optional().or(emptyStringToUndefined), + line_1: z + .string({ required_error: "You need to provided an address." }) + .min(1), + line_2: z.string().min(1).optional().or(emptyStringToUndefined), + city: z.string().min(1).optional().or(emptyStringToUndefined), + county: z.string().min(1).optional().or(emptyStringToUndefined), + region: z + .string({ required_error: "You need to provided a region." }) + .optional() + .or(emptyStringToUndefined), + postcode: z + .string({ required_error: "You need to provided a postcode." }) + .min(1), + country: z + .string({ required_error: "You need to provided a country." }) + .min(1), +}); + +export const shippingAddressSchema = z + .object({ + phone_number: z + .string() + .regex( + /^(\+?\d{0,4})?\s?-?\s?(\(?\d{3}\)?)\s?-?\s?(\(?\d{3}\)?)\s?-?\s?(\(?\d{4}\)?)?$/, + "Phone number is not valid", + ) + .optional() + .or(emptyStringToUndefined), + instructions: z.string().min(1).optional().or(emptyStringToUndefined), + }) + .merge(billingAddressSchema); + +export const anonymousCheckoutFormSchema = z.object({ + guest: guestInformationSchema, + shippingAddress: shippingAddressSchema, + sameAsShipping: z.boolean().default(true), + billingAddress: billingAddressSchema.optional().or(emptyStringToUndefined), + shippingMethod: z + .union([z.literal("__shipping_standard"), z.literal("__shipping_express")]) + .default("__shipping_standard"), +}); + +export type AnonymousCheckoutForm = z.TypeOf< + typeof anonymousCheckoutFormSchema +>; + +export const accountMemberCheckoutFormSchema = z.object({ + account: accountMemberInformationSchema, + shippingAddress: shippingAddressSchema, + sameAsShipping: z.boolean().default(true), + billingAddress: billingAddressSchema.optional().or(emptyStringToUndefined), + shippingMethod: z + .union([z.literal("__shipping_standard"), z.literal("__shipping_express")]) + .default("__shipping_standard"), +}); + +export type AccountMemberCheckoutForm = z.TypeOf< + typeof accountMemberCheckoutFormSchema +>; + +export const checkoutFormSchema = z.union([ + anonymousCheckoutFormSchema, + accountMemberCheckoutFormSchema, +]); + +export type CheckoutForm = z.TypeOf; diff --git a/examples/global-services/src/components/featured-products/FeaturedProducts.tsx b/examples/global-services/src/components/featured-products/FeaturedProducts.tsx new file mode 100644 index 00000000..909ec1ef --- /dev/null +++ b/examples/global-services/src/components/featured-products/FeaturedProducts.tsx @@ -0,0 +1,86 @@ +"use server"; +import clsx from "clsx"; +import Link from "next/link"; +import { ArrowRightIcon, EyeSlashIcon } from "@heroicons/react/24/outline"; +import Image from "next/image"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { fetchFeaturedProducts } from "./fetchFeaturedProducts"; + +interface IFeaturedProductsProps { + title: string; + linkProps?: { + link: string; + text: string; + }; +} + +export default async function FeaturedProducts({ + title, + linkProps, +}: IFeaturedProductsProps) { + const client = getServerSideImplicitClient(); + const products = await fetchFeaturedProducts(client); + + return ( +
+
+

+ {title} +

+ {linkProps && ( + + + {linkProps.text} + + + )} +
+
    + {products.map((product) => ( + +
  • +
    +
    + {product.main_image?.link.href ? ( + {product.main_image?.file_name!} + ) : ( +
    + +
    + )} +
    +
    +

    + {product.attributes.name} +

    +

    + {product.meta.display_price?.without_tax?.formatted} +

    +
  • + + ))} +
+
+ ); +} diff --git a/examples/global-services/src/components/featured-products/fetchFeaturedProducts.ts b/examples/global-services/src/components/featured-products/fetchFeaturedProducts.ts new file mode 100644 index 00000000..a5b9f7bf --- /dev/null +++ b/examples/global-services/src/components/featured-products/fetchFeaturedProducts.ts @@ -0,0 +1,19 @@ +import { getProducts } from "../../services/products"; +import { ElasticPath } from "@elasticpath/js-sdk"; +import { ProductResponseWithImage } from "../../lib/types/product-types"; +import { connectProductsWithMainImages } from "../../lib/connect-products-with-main-images"; + +// Fetching the first 4 products of in the catalog to display in the featured-products component +export const fetchFeaturedProducts = async ( + client: ElasticPath, +): Promise => { + const { data: productsResponse, included: productsIncluded } = + await getProducts(client); + + return productsIncluded?.main_images + ? connectProductsWithMainImages( + productsResponse.slice(0, 4), // Only need the first 4 products to feature + productsIncluded?.main_images, + ) + : productsResponse; +}; diff --git a/examples/global-services/src/components/footer/Footer.tsx b/examples/global-services/src/components/footer/Footer.tsx new file mode 100644 index 00000000..46759f53 --- /dev/null +++ b/examples/global-services/src/components/footer/Footer.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; +import { PhoneIcon, InformationCircleIcon } from "@heroicons/react/24/solid"; +import { GitHubIcon } from "../icons/github-icon"; +import EpLogo from "../icons/ep-logo"; + +const Footer = (): JSX.Element => ( +
+
+
+
+ +
+
+ + Home + + + Shipping + + + FAQ + +
+
+ + About + + + Terms + +
+
+
+ + {" "} + + + + {" "} + + + + + +
+
+
+
+); + +export default Footer; diff --git a/examples/global-services/src/components/form/Form.tsx b/examples/global-services/src/components/form/Form.tsx new file mode 100644 index 00000000..4f760fa5 --- /dev/null +++ b/examples/global-services/src/components/form/Form.tsx @@ -0,0 +1,183 @@ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; +import { cn } from "../../lib/cn"; +import { Label } from "../label/Label"; +import { + ComponentPropsWithoutRef, + createContext, + ElementRef, + forwardRef, + HTMLAttributes, + useContext, + useId, +} from "react"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = useContext(FormFieldContext); + const itemContext = useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = createContext( + {} as FormItemContextValue, +); + +const FormItem = forwardRef>( + ({ className, ...props }, ref) => { + const id = useId(); + + return ( + +
+ + ); + }, +); +FormItem.displayName = "FormItem"; + +const FormLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +
+
+ ); +}; + +export default ProductVariationColor; diff --git a/examples/global-services/src/components/product/variations/ProductVariationStandard.tsx b/examples/global-services/src/components/product/variations/ProductVariationStandard.tsx new file mode 100644 index 00000000..a326191c --- /dev/null +++ b/examples/global-services/src/components/product/variations/ProductVariationStandard.tsx @@ -0,0 +1,55 @@ +import clsx from "clsx"; +import type { useVariationProduct } from "@elasticpath/react-shopper-hooks"; + +interface ProductVariationOption { + id: string; + description: string; + name: string; +} + +export type UpdateOptionHandler = ( + variationId: string, +) => (optionId: string) => void; + +interface IProductVariation { + variation: { + id: string; + name: string; + options: ProductVariationOption[]; + }; + updateOptionHandler: ReturnType< + typeof useVariationProduct + >["updateSelectedOptions"]; + selectedOptionId?: string; +} + +const ProductVariationStandard = ({ + variation, + selectedOptionId, + updateOptionHandler, +}: IProductVariation): JSX.Element => { + return ( +
+

{variation.name}

+
+ {variation.options.map((o) => ( + + ))} +
+
+ ); +}; + +export default ProductVariationStandard; diff --git a/examples/global-services/src/components/product/variations/ProductVariations.tsx b/examples/global-services/src/components/product/variations/ProductVariations.tsx new file mode 100644 index 00000000..4ec4e1e2 --- /dev/null +++ b/examples/global-services/src/components/product/variations/ProductVariations.tsx @@ -0,0 +1,108 @@ +import type { CatalogsProductVariation } from "@elasticpath/js-sdk"; +import { useRouter } from "next/navigation"; +import { useContext } from "react"; +import { useEffect } from "react"; +import { OptionDict } from "../../../lib/types/product-types"; +import { + allVariationsHaveSelectedOption, + getSkuIdFromOptions, +} from "../../../lib/product-helper"; +import ProductVariationStandard from "./ProductVariationStandard"; +import ProductVariationColor from "./ProductVariationColor"; +import { useVariationProduct } from "@elasticpath/react-shopper-hooks"; +import { ProductContext } from "../../../lib/product-context"; + +const getSelectedOption = ( + variationId: string, + optionLookupObj: OptionDict, +): string => { + return optionLookupObj[variationId]; +}; + +const ProductVariations = (): JSX.Element => { + const { + variations, + variationsMatrix, + product, + selectedOptions, + updateSelectedOptions, + } = useVariationProduct(); + + const currentProductId = product.response.id; + + const context = useContext(ProductContext); + + const router = useRouter(); + + useEffect(() => { + const selectedSkuId = getSkuIdFromOptions( + Object.values(selectedOptions), + variationsMatrix, + ); + + if ( + !context?.isChangingSku && + selectedSkuId && + selectedSkuId !== currentProductId && + allVariationsHaveSelectedOption(selectedOptions, variations) + ) { + context?.setIsChangingSku(true); + router.replace(`/products/${selectedSkuId}`, { scroll: false }); + context?.setIsChangingSku(false); + } + }, [ + selectedOptions, + context, + currentProductId, + router, + variations, + variationsMatrix, + ]); + + return ( +
+ {variations.map((v) => + resolveVariationComponentByName( + v, + updateSelectedOptions, + getSelectedOption(v.id, selectedOptions), + ), + )} +
+ ); +}; + +function resolveVariationComponentByName( + v: CatalogsProductVariation, + updateOptionHandler: ReturnType< + typeof useVariationProduct + >["updateSelectedOptions"], + selectedOptionId?: string, +): JSX.Element { + switch (v.name.toLowerCase()) { + case "color": + return ( + + ); + default: + return ( + + ); + } +} + +export default ProductVariations; diff --git a/examples/global-services/src/components/product/variations/VariationProduct.tsx b/examples/global-services/src/components/product/variations/VariationProduct.tsx new file mode 100644 index 00000000..77fb2bc4 --- /dev/null +++ b/examples/global-services/src/components/product/variations/VariationProduct.tsx @@ -0,0 +1,60 @@ +"use client"; +import { + useCartAddProduct, + useVariationProduct, + VariationProduct, + VariationProductProvider, +} from "@elasticpath/react-shopper-hooks"; +import ProductVariations from "./ProductVariations"; +import ProductCarousel from "../carousel/ProductCarousel"; +import ProductSummary from "../ProductSummary"; +import ProductDetails from "../ProductDetails"; +import ProductExtensions from "../ProductExtensions"; +import { StatusButton } from "../../button/StatusButton"; + +export const VariationProductDetail = ({ + variationProduct, +}: { + variationProduct: VariationProduct; +}): JSX.Element => { + return ( + + + + ); +}; + +export function VariationProductContainer(): JSX.Element { + const { product } = useVariationProduct(); + const { mutate, isPending } = useCartAddProduct(); + + const { response, main_image, otherImages } = product; + const { extensions } = response.attributes; + return ( +
+
+
+ {main_image && ( + + )} +
+
+
+ + + + {extensions && } + mutate({ productId: response.id, quantity: 1 })} + status={isPending ? "loading" : "idle"} + > + ADD TO CART + +
+
+
+
+ ); +} diff --git a/examples/global-services/src/components/promotion-banner/PromotionBanner.tsx b/examples/global-services/src/components/promotion-banner/PromotionBanner.tsx new file mode 100644 index 00000000..4430aa13 --- /dev/null +++ b/examples/global-services/src/components/promotion-banner/PromotionBanner.tsx @@ -0,0 +1,62 @@ +"use client"; +import { useRouter } from "next/navigation"; +import clsx from "clsx"; + +export interface IPromotion { + title?: string; + description?: string; + imageHref?: string; +} + +interface IPromotionBanner { + linkProps?: { + link: string; + text: string; + }; + alignment?: "center" | "left" | "right"; + promotion: IPromotion; +} + +const PromotionBanner = (props: IPromotionBanner): JSX.Element => { + const router = useRouter(); + const { linkProps, promotion } = props; + + const { title, description } = promotion; + + return ( + <> + {promotion && ( +
+
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} + {linkProps && ( + + )} +
+
+ )} + + ); +}; + +export default PromotionBanner; diff --git a/examples/global-services/src/components/radio-group/RadioGroup.tsx b/examples/global-services/src/components/radio-group/RadioGroup.tsx new file mode 100644 index 00000000..b9f3ba24 --- /dev/null +++ b/examples/global-services/src/components/radio-group/RadioGroup.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { cn } from "../../lib/cn"; + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + + + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/examples/global-services/src/components/recommendations/RecommendationProducts.tsx b/examples/global-services/src/components/recommendations/RecommendationProducts.tsx new file mode 100644 index 00000000..f57aa99e --- /dev/null +++ b/examples/global-services/src/components/recommendations/RecommendationProducts.tsx @@ -0,0 +1,51 @@ +"use client"; +import { ShopperProduct } from "@elasticpath/react-shopper-hooks"; +import { useEffect, useState } from "react"; +import { KlevuRecord } from "@klevu/core"; +import Hits from "../search/Hits"; +import HitPlaceholder from "../search/HitPlaceholder"; +import { fetchFeatureProducts, fetchSimilarProducts } from "../../lib/klevu"; + +export function RecommendedProducts({ + product, +}: { + product: ShopperProduct; +}) { + + const [products, setProducts] = useState(); + + const doFetch = async () => { + const productId = product.response.id; + + const similarProductsResponse = await fetchSimilarProducts(productId); + const similarProducts = similarProductsResponse?.[0]?.records; + + if (similarProducts && similarProducts.length > 0) { + setProducts(similarProducts); + } else { + const resp = await fetchFeatureProducts(); + setProducts(resp.records); + } + } + + useEffect(() => { + doFetch(); + }, []); + + return ( + <> +

You might also like

+ {products && } + {!products && +
+
+ +
+
+ } + + + ); +} diff --git a/examples/global-services/src/components/search/Hit.tsx b/examples/global-services/src/components/search/Hit.tsx new file mode 100644 index 00000000..27c15914 --- /dev/null +++ b/examples/global-services/src/components/search/Hit.tsx @@ -0,0 +1,90 @@ +import Link from "next/link"; +import Price from "../product/Price"; +import StrikePrice from "../product/StrikePrice"; +import Image from "next/image"; +import { EyeSlashIcon } from "@heroicons/react/24/solid"; +import { KlevuRecord } from "@klevu/core"; + +function formatCurrency(price: string, currency: string) { + return Intl.NumberFormat(undefined, { + style: "currency", + currency + }).format(parseFloat(price)); +} + +export default function HitComponent({ + hit, + clickEvent +}: { + hit: KlevuRecord; + clickEvent?: (params: { productId: string}) => void +}): JSX.Element { + const { + image: ep_main_image_url, + price, + salePrice, + id, + name, + shortDesc: description, + currency + } = hit; + + return ( + <> + +
clickEvent ? clickEvent({ productId: id}) : null } + > +
+ {ep_main_image_url ? ( + {name} + ) : ( +
+ +
+ )} +
+
+
+ +

{name}

+ + + {description} + +
+
+ {price && ( +
+ + {price && (price !== salePrice) && ( + + )} +
+ )} +
+
+
+ + + ); +} diff --git a/examples/global-services/src/components/search/HitPlaceholder.tsx b/examples/global-services/src/components/search/HitPlaceholder.tsx new file mode 100644 index 00000000..9ac2a907 --- /dev/null +++ b/examples/global-services/src/components/search/HitPlaceholder.tsx @@ -0,0 +1,29 @@ +export default function HitPlaceholder(): JSX.Element { + return ( + <> +
+
+
+
+
+ + Loading... +
+
+
+
+
+ + +
+
+
+
+ + ); +} diff --git a/examples/global-services/src/components/search/Hits.tsx b/examples/global-services/src/components/search/Hits.tsx new file mode 100644 index 00000000..533e9f24 --- /dev/null +++ b/examples/global-services/src/components/search/Hits.tsx @@ -0,0 +1,28 @@ +import NoResults from "./NoResults"; +import HitComponent from "./Hit"; +import { KlevuRecord } from "@klevu/core"; + +type HitsProps = { + data: KlevuRecord[]; + clickEvent?: (params: { productId: string}) => void +} + +export default function Hits({ data, clickEvent }: HitsProps): JSX.Element { + if (data.length) { + return ( +
+ {data.map((hit) => { + return ( +
+ +
+ ); + })} +
+ ); + } + return ; +} diff --git a/examples/global-services/src/components/search/MobileFilters.tsx b/examples/global-services/src/components/search/MobileFilters.tsx new file mode 100644 index 00000000..44c7c2e9 --- /dev/null +++ b/examples/global-services/src/components/search/MobileFilters.tsx @@ -0,0 +1,72 @@ +import { BreadcrumbLookup } from "../../lib/types/breadcrumb-lookup"; +import { Dialog, Transition } from "@headlessui/react"; +import { Dispatch, Fragment, SetStateAction } from "react"; +import { XMarkIcon } from "@heroicons/react/24/solid"; +import NodeMenu from "./NodeMenu"; + +interface IMobileFilters { + lookup?: BreadcrumbLookup; + showFilterMenu: boolean; + setShowFilterMenu: Dispatch>; +} + +export default function MobileFilters({ + showFilterMenu, + setShowFilterMenu, +}: IMobileFilters): JSX.Element { + return ( + + setShowFilterMenu(false)} + > + +
+ +
+
+
+ + +
+ +
+ +
+ Category + +
+
+
+
+
+
+
+
+ ); +} diff --git a/examples/global-services/src/components/search/NoResults.tsx b/examples/global-services/src/components/search/NoResults.tsx new file mode 100644 index 00000000..535b0cac --- /dev/null +++ b/examples/global-services/src/components/search/NoResults.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +export const NoResults = (): JSX.Element => { + return ( +
+
+ No matching results +
+
+ ); +}; + +export default NoResults; diff --git a/examples/global-services/src/components/search/NodeMenu.tsx b/examples/global-services/src/components/search/NodeMenu.tsx new file mode 100644 index 00000000..3ea1ddb3 --- /dev/null +++ b/examples/global-services/src/components/search/NodeMenu.tsx @@ -0,0 +1,100 @@ +import { clsx } from "clsx"; +import { useFacetClicked, usePageContext } from "./ProductsProvider"; +import { KlevuFilterResultOptions } from "@klevu/core"; +import { Facet } from "./product-specification/Facets"; + +function isPathActive(activePath: string, providedPath: string): boolean { + const targetPathParts = activePath.split("/").filter(Boolean); + const providedPathParts = providedPath.split("/").filter(Boolean); + + if (providedPathParts.length > targetPathParts.length) { + return false; + } + + return providedPathParts.every( + (part, index) => part === targetPathParts[index], + ); +} + +function MenuItem({ item, filter }: { item: KlevuFilterResultOptions | Facet, filter?: KlevuFilterResultOptions }): JSX.Element { + const activeItem = 'selected' in item ? item.selected : false; + const facetClicked = useFacetClicked(); + const label = 'label' in item ? item.label : item.name; + const options = 'options' in item ? item.options : []; + return ( +
  • + {filter && + } + {!filter &&
    { + if(filter) facetClicked(filter, item as Facet) + if(label === "All Products") facetClicked((item as any).categoryFilter, { name: "all" } as Facet)} + } + className={clsx( + "ais-HierarchicalMenu-link", + activeItem && clsx("font-bold text-brand-primary"), + )} + > + {label} +
    } + {!!options.length && ( +
    + +
    + )} +
  • + ); +} + +function MenuList({ items, filter }: { items: KlevuFilterResultOptions[] | Facet[], filter?: KlevuFilterResultOptions }) { + return ( +
      + {items.map((item) => ( + item ? : <> + ))} +
    + ); +} + +function isSelectedFacet(options: KlevuFilterResultOptions[]) { + return options?.some((option) => option?.options?.some((facet) => facet.selected)) +} + +export default function NodeMenu(): JSX.Element { + const pageContext = usePageContext(); + const filters = pageContext?.filters || []; + if(!filters.length) { + return <> + } + let categoryFilter = filters ? filters.find((filter) => filter.key === "category") : []; + const navWithAllProducts = [ + { + key: "all", + label: "All Products", + selected: !isSelectedFacet(filters as KlevuFilterResultOptions[]), + categoryFilter + } as any, + categoryFilter, + ]; + return ( +
    + +
    + ); +} diff --git a/examples/global-services/src/components/search/Pagination.tsx b/examples/global-services/src/components/search/Pagination.tsx new file mode 100644 index 00000000..e6e5f2db --- /dev/null +++ b/examples/global-services/src/components/search/Pagination.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { usePathname } from "next/navigation"; +import { + Pagination as DisplayPagination, + PaginationContent, + PaginationItem, PaginationLink, +} from "../pagination/Pagination"; +import { KlevuQueryResult } from "@klevu/core"; + +export const DEFAULT_LIMIT = 12; + +function calculateTotalPages(totalItems: number, limit: number): number { + // Ensure both totalItems and limit are positive integers + totalItems = Math.max(0, Math.floor(totalItems)); + limit = Math.max(1, Math.floor(limit)); + + // Calculate the total number of pages using the formula + return Math.ceil(totalItems / limit); +} + +export const Pagination = ({page}: {page: KlevuQueryResult}): JSX.Element => { + const pathname = usePathname(); + + if (!page) { + return <>; + } + + const totalPages = calculateTotalPages(page.meta.totalResultsFound, DEFAULT_LIMIT); + + return ( + + + {[...Array(totalPages).keys()].map((pageNumber) => ( + + {pageNumber + 1} + + ))} + + + ) +}; + +export default Pagination; diff --git a/examples/global-services/src/components/search/ProductsProvider.tsx b/examples/global-services/src/components/search/ProductsProvider.tsx new file mode 100644 index 00000000..bd2e8899 --- /dev/null +++ b/examples/global-services/src/components/search/ProductsProvider.tsx @@ -0,0 +1,192 @@ +import { + createContext, + useContext, + useEffect, + useState, +} from "react"; +import { FilterManager, KlevuFilterResultOptions, KlevuResponseObject, KlevuSearchOptions, KlevuSearchSorting } from "@klevu/core"; +import { fetchProducts } from "../../lib/klevu"; +import { usePathname, useSearchParams } from "next/navigation"; +import { DEFAULT_LIMIT } from "./Pagination"; +import { DEFAULT_MAX_VAL, DEFAULT_MIN_VAL } from "./price-range-slider/PriceRangeSlider"; +import { Facet } from "./product-specification/Facets"; + +type ProductsSettings = { + priceRange: number[]; + limit: number; + offset: number; + sortBy: KlevuSearchSorting.PriceAsc | KlevuSearchSorting.PriceDesc | undefined, +} + +type SettingsKey = keyof ProductsSettings + +interface ProductsState { + page?: { data: KlevuResponseObject | undefined, loading: boolean }; + settings: ProductsSettings; + adjustSettings: ((settings: Partial) => void); + facetClicked: (filter: KlevuFilterResultOptions, facet?: Facet) => void; +} + +export const ProductsProviderContext = createContext( null ); + +export type ProductsProviderProps = { + children: React.ReactNode; +}; + +function searchSettings(productsSettings: ProductsSettings): Partial { + const settings: Partial = { + limit: productsSettings.limit, + typeOfRecords: ["KLEVU_PRODUCT"], + offset: productsSettings.offset, + sort: productsSettings.sortBy || undefined, + }; + + if(productsSettings.priceRange[0] !== DEFAULT_MIN_VAL || productsSettings.priceRange[1] !== DEFAULT_MAX_VAL) { + settings.groupCondition = { + groupOperator: "ANY_OF", + conditions: [ + { + key: "klevu_price", + valueOperator: "INCLUDE", + singleSelect: true, + excludeValuesInResult: true, + values: [ + `${productsSettings.priceRange[0]} - ${productsSettings.priceRange[1]}` + ] + } + ] + } + } + return settings; +} + +function convertToTitleCase(text: string): string { + return text + .split('-') // Split the string by hyphens + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) // Capitalize the first letter of each word + .join(' '); // Join the words back together with spaces +} + +const manager = new FilterManager(); + +export const ProductsProvider = ({ + children, +}: ProductsProviderProps) => { + const [initRange, setInitRange] = useState<[Number, Number]>() + const searchParams = useSearchParams(); + const DEFAULT_SETTINGS: ProductsSettings = { + limit: Number(searchParams.get("limit")) || DEFAULT_LIMIT, + offset: Number(searchParams.get("offset")) || 0, + priceRange: [DEFAULT_MIN_VAL, DEFAULT_MAX_VAL], + sortBy: undefined, + } + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [page, setPage] = useState<{ data: KlevuResponseObject | undefined, loading: boolean }>({ data: undefined, loading: false}); + const pathname = usePathname(); + + const fetchProductsData = async () => { + try { + const data = await fetchProducts(searchSettings(settings), undefined, manager); + const priceRangeFilter: any = data.queriesById("search").filters?.find((filter) => filter.key === "klevu_price"); + if(!initRange) { + setInitRange([priceRangeFilter.min, priceRangeFilter.max]); + setPage({ data, loading: false }); + } else { + priceRangeFilter.min = initRange[0]; + priceRangeFilter.max = initRange[1]; + setPage({ data, loading: false }); + } + + } catch (error) { + console.error("Failed to fetch products:", error); + } + }; + + const adjustSettings = (newSettings: Partial) => { + const offset = newSettings.offset || 0; + setSettings({...settings, ...newSettings, offset}); + } + + useEffect(() => { + if(searchParams.get("offset")) { + adjustSettings({ offset: Number(searchParams.get("offset")) }); + } + }, [searchParams.get("offset")]) + + const facetClicked = (filter: KlevuFilterResultOptions, facet?: Facet) => { + if(facet?.selected) { + manager.deselectOption(filter.key, facet.value); + } else { + if(facet && facet.name === "all") { + filter.options.forEach((option) => { + manager.deselectOption(filter.key, option.value); + }) + } + else if(facet) { + manager.selectOption(filter.key, facet.value); + } + } + setSettings({...settings, offset: 0}) + } + + useEffect(() => { + if(manager && manager.filters?.length) { + const filter = manager.filters[0] as KlevuFilterResultOptions + filter.options.forEach((option) => manager.deselectOption("category", option.value)) + } + const pathSegments = pathname.replace('/search', '').split('/').filter(Boolean); + pathSegments.forEach((segment) => { + manager.selectOption("category", convertToTitleCase(segment)); + }); + }, []); + + useEffect(() => { + fetchProductsData(); + }, [settings]); + + return ( + + {children} + + ); +}; + +export function usePageContext() { + const context = useContext(ProductsProviderContext); + if (context === null) { + throw new Error("usePageContext must be used within a ProductsProvider"); + } + return context?.page?.data?.queriesById("search"); +} + +export function useResponseObject() { + const context = useContext(ProductsProviderContext); + if (context === null) { + throw new Error("useResponseObject must be used within a ProductsProvider"); + } + return context?.page; +} + +export function useFacetClicked() { + const context = useContext(ProductsProviderContext); + if (context === null) { + throw new Error("facetClicked must be used within a ProductsProvider"); + } + return context.facetClicked; +} + +export function useSettings(key: SettingsKey) { + const context = useContext(ProductsProviderContext); + if (context === null) { + throw new Error("settings must be used within a ProductsProvider"); + } + + const setSetting = (value: typeof context.settings[SettingsKey]) => { + context.adjustSettings({ [key]: value }); + }; + + return { + [key]: context.settings[key], + [`set${key.charAt(0).toUpperCase() + key.slice(1)}`]: setSetting + }; +} diff --git a/examples/global-services/src/components/search/SearchModal.tsx b/examples/global-services/src/components/search/SearchModal.tsx new file mode 100644 index 00000000..ed592570 --- /dev/null +++ b/examples/global-services/src/components/search/SearchModal.tsx @@ -0,0 +1,216 @@ +"use client"; +import { Fragment, useState } from "react"; +import { useRouter } from "next/navigation"; +import NoResults from "./NoResults"; +import { useDebouncedEffect } from "../../lib/use-debounced"; +import { EP_CURRENCY_CODE } from "../../lib/resolve-ep-currency-code"; +import { XMarkIcon, MagnifyingGlassIcon } from "@heroicons/react/24/solid"; +import Image from "next/image"; +import Link from "next/link"; +import clsx from "clsx"; +import { Dialog, Transition } from "@headlessui/react"; +import * as React from "react"; +import NoImage from "../NoImage"; +import { fetchProducts } from "../../lib/klevu"; +import { KlevuRecord } from "@klevu/core"; + +const SearchBox = ({ + onChange, + onSearchEnd, + setHits, +}: { + onChange: (value: string) => void; + onSearchEnd: (query: string) => void; + setHits: React.Dispatch> +}) => { + const [search, setSearch] = useState(""); + + const doSearch = async () => { + const resp = await fetchProducts({}, search); + setHits(resp.queriesById("search").records); + } + + useDebouncedEffect(() => { doSearch() }, 400, [search]); + + return ( +
    +
    + +
    + { + setSearch(event.target.value); + onChange(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + onSearchEnd(search); + } + }} + placeholder="Search" + /> +
    + +
    +
    + ); +}; + +const HitComponent = ({ hit, closeModal }: { hit: KlevuRecord, closeModal: () => void }) => { + const { price, image, name, sku, id } = hit; + + return ( + +
    +
    + {image ? ( + {name} + ) : ( + + )} +
    +
    + {name} +
    +
    + + {sku} + +
    +
    + {price && ( + + {price} + + )} +
    +
    + + ); +}; + +const Hits = ({ hits, closeModal }: { hits: KlevuRecord[], closeModal: () => void }) => { + if (hits.length) { + return ( +
      + {hits.map((hit) => ( +
    • + +
    • + ))} +
    + ); + } + return ; +}; + +export const SearchModal = (): JSX.Element => { + const [searchValue, setSearchValue] = useState(""); + const router = useRouter(); + const [hits, setHits] = useState([]) + let [isOpen, setIsOpen] = useState(false); + + function closeModal() { + setIsOpen(false); + } + + function openModal() { + setIsOpen(true); + } + + return ( +
    + + + + +
    + + +
    +
    + + + { + setSearchValue(value); + }} + onSearchEnd={(query) => { + closeModal(); + setSearchValue(""); + router.push(`/search?q=${query}`); + }} + setHits={setHits} + /> + {searchValue ? ( +
    +
    +
    + +
    +
    + ) : null} +
    +
    +
    +
    +
    +
    +
    + ); +}; + +export default SearchModal; \ No newline at end of file diff --git a/examples/global-services/src/components/search/SearchResults.tsx b/examples/global-services/src/components/search/SearchResults.tsx new file mode 100644 index 00000000..11801148 --- /dev/null +++ b/examples/global-services/src/components/search/SearchResults.tsx @@ -0,0 +1,116 @@ +"use client"; +import Hits from "./Hits"; +import Pagination from "./Pagination"; +import { Fragment, useState } from "react"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import NodeMenu from "./NodeMenu"; +import { ProductsProvider, usePageContext, useSettings } from "./ProductsProvider"; +import MobileFilters from "./MobileFilters"; +import NoResults from "./NoResults"; +import PriceRangeSlider from "./price-range-slider/PriceRangeSliderWrapper"; +import { Popover, Transition } from "@headlessui/react"; +import { sortByItems } from "../../lib/sort-by-items"; + +type sortBySetting = { + sortBy: string, + setSortBy: (value: string | undefined) => void +} + +export default function SearchResults(): JSX.Element { + let [showFilterMenu, setShowFilterMenu] = useState(false); + // const title = nodes ? resolveTitle(nodes, lookup) : "All Categories"; + const title = "Catalog" + + return ( + +
    +
    +
    + {title} +
    +
    +
    + + +
    + +
    +
    +
    +
    +
    +

    Category

    + + { // attribute={EP_ROUTE_PRICE} + } + +
    + + +
    +
    +
    + ); +} + +const HitsUI = (): JSX.Element => { + const pageContext = usePageContext(); + return
    + {pageContext?.records && + <> + +
    + +
    + + } + {!pageContext?.records && } +
    +} + +const SortBy = (): JSX.Element => { + const { setSortBy } = useSettings('sortBy') as sortBySetting; + return ( +
    + + {({}) => ( + <> + + Sort + + + +
    + {sortByItems.map((option) => ( + setSortBy(option.value)} + > + {option.label} + + ))} +
    +
    +
    + + )} +
    +
    ) +} diff --git a/examples/global-services/src/components/search/price-range-slider/PriceRangeSlider.tsx b/examples/global-services/src/components/search/price-range-slider/PriceRangeSlider.tsx new file mode 100644 index 00000000..c4e7bc95 --- /dev/null +++ b/examples/global-services/src/components/search/price-range-slider/PriceRangeSlider.tsx @@ -0,0 +1,70 @@ +import { MinusIcon } from "@heroicons/react/24/solid"; +import { useCallback, useState } from "react"; +import Slider from "rc-slider"; +import "rc-slider/assets/index.css"; +import { useSettings } from "../ProductsProvider"; +import throttle from "lodash/throttle"; + +export type PriceRange = [number, number]; + +export const DEFAULT_MIN_VAL = 0; +export const DEFAULT_MAX_VAL = 300; + +type PriceRangeSetting = { + priceRange: number[], + setPriceRange: (value: number | number[]) => void +} + +const PriceRangeSlider = () => { + const {priceRange, setPriceRange} = useSettings('priceRange') as PriceRangeSetting; + const [range, setRange] = useState(priceRange); + + const throttledSetPriceRange = useCallback( + throttle((minVal: number, maxVal: number) => { + setPriceRange([minVal, maxVal]); + }, 1000), + [] + ); + + const handleSliderChange = (val: number[], updatePriceRange?: boolean) => { + const [minVal, maxVal] = val; + setRange([minVal, maxVal]); + + if(updatePriceRange) { + throttledSetPriceRange(minVal, maxVal); + } + }; + + return ( +
    +
    + handleSliderChange([Number(e.target.value), range[1]], true)} + /> + + handleSliderChange([range[0], Number(e.target.value)], true)} + /> +
    + + { handleSliderChange(val as number[])}} + onChangeComplete={(val) => throttledSetPriceRange((val as number[])[0], (val as number[])[1])} + /> +
    + ); +}; + +export default PriceRangeSlider; diff --git a/examples/global-services/src/components/search/price-range-slider/PriceRangeSliderWrapper.tsx b/examples/global-services/src/components/search/price-range-slider/PriceRangeSliderWrapper.tsx new file mode 100644 index 00000000..977cd341 --- /dev/null +++ b/examples/global-services/src/components/search/price-range-slider/PriceRangeSliderWrapper.tsx @@ -0,0 +1,10 @@ +import PriceRangeSliderComponent, { PriceRange } from "./PriceRangeSlider"; + +export default function PriceRangeSliderWrapper(): JSX.Element { + return ( + <> +

    Price

    + + + ); +} diff --git a/examples/global-services/src/components/search/product-specification/Facets.tsx b/examples/global-services/src/components/search/product-specification/Facets.tsx new file mode 100644 index 00000000..60b5ad5b --- /dev/null +++ b/examples/global-services/src/components/search/product-specification/Facets.tsx @@ -0,0 +1,6 @@ +export type Facet = { +name: string; +value: string; +count: number; +selected: boolean; +} \ No newline at end of file diff --git a/examples/global-services/src/components/select/Select.tsx b/examples/global-services/src/components/select/Select.tsx new file mode 100644 index 00000000..e5195705 --- /dev/null +++ b/examples/global-services/src/components/select/Select.tsx @@ -0,0 +1,183 @@ +"use client"; + +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import { cn } from "../../lib/cn"; +import { ChevronUpIcon } from "@heroicons/react/20/solid"; +import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/outline"; +import { cva, VariantProps } from "class-variance-authority"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const selectTriggerVariants = cva( + "flex w-full text-black/80 rounded-lg justify-between items-center border border-black/40 focus-visible:ring-0 focus-visible:border-black bg-white disabled:cursor-not-allowed disabled:opacity-50 leading-[1.6rem]", + { + variants: { + sizeKind: { + default: "px-4 py-[0.78rem]", + medium: "px-4 py-[0.44rem]", + mediumUntilSm: "px-4 py-[0.44rem] sm:px-4 sm:py-[0.78rem]", + }, + }, + defaultVariants: { + sizeKind: "default", + }, + }, +); + +export type SelectTriggerProps = React.ComponentPropsWithoutRef< + typeof SelectPrimitive.Trigger +> & + VariantProps; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + SelectTriggerProps +>(({ className, children, sizeKind, ...props }, ref) => ( + span]:line-clamp-1", + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/examples/global-services/src/components/separator/Separator.tsx b/examples/global-services/src/components/separator/Separator.tsx new file mode 100644 index 00000000..8a98a5e1 --- /dev/null +++ b/examples/global-services/src/components/separator/Separator.tsx @@ -0,0 +1,29 @@ +"use client"; +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "../../lib/cn"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref, + ) => ( + + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/examples/global-services/src/components/shared/blurb.tsx b/examples/global-services/src/components/shared/blurb.tsx new file mode 100644 index 00000000..87df7aae --- /dev/null +++ b/examples/global-services/src/components/shared/blurb.tsx @@ -0,0 +1,76 @@ +import { ReactNode } from "react"; + +const Para = ({ children }: { children: ReactNode }) => { + return
    {children}
    ; +}; + +interface IBlurbProps { + title: string; +} + +const Blurb = ({ title }: IBlurbProps) => ( +
    +

    {title}

    + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec arcu + lectus, pharetra nec velit in, vehicula suscipit tellus. Quisque id mollis + magna. Cras nec lacinia ligula. Morbi aliquam tristique purus, nec dictum + metus euismod at. Vestibulum mollis metus lobortis lectus sodales + eleifend. Class aptent taciti sociosqu ad litora torquent per conubia + nostra, per inceptos himenaeos. Vivamus eget elementum eros, et ultricies + mi. Donec eget dolor imperdiet, gravida ante a, molestie tortor. Nullam + viverra, orci gravida sollicitudin auctor, urna magna condimentum risus, + vitae venenatis turpis mauris sed ligula. Fusce mattis, mauris ut eleifend + ullamcorper, dui felis tincidunt libero, ut commodo arcu leo a ligula. + Cras congue maximus magna, et porta nisl pulvinar in. Nam congue orci + ornare scelerisque elementum. Quisque purus justo, molestie ut leo at, + tristique pretium dui. + + + + Vestibulum imperdiet commodo egestas. Proin tincidunt leo non purus + euismod dictum. Vivamus sagittis mauris dolor, quis egestas purus placerat + eget. Mauris finibus scelerisque augue ut ultrices. Praesent vitae nulla + lorem. Ut eget accumsan risus, sed fringilla orci. Nunc volutpat, odio vel + ornare ullamcorper, massa mauris dapibus nunc, sed euismod lectus erat + eget ligula. Duis fringilla elit vel eleifend luctus. Quisque non blandit + magna. Vivamus pharetra, dolor sed molestie ultricies, tellus ex egestas + lacus, in posuere risus diam non massa. Phasellus in justo in urna + faucibus cursus. + + + + Nullam nibh nisi, lobortis at rhoncus ut, viverra at turpis. Mauris ac + sollicitudin diam. Phasellus non orci massa. Donec tincidunt odio justo. + Sed gravida leo turpis, vitae blandit sem pharetra sit amet. Vestibulum + ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; Orci varius natoque penatibus et magnis dis parturient montes, + nascetur ridiculus mus. + + + + In in pulvinar turpis, vel pulvinar ipsum. Praesent vel commodo nisi, id + maximus ex. Integer lorem augue, hendrerit et enim vel, eleifend blandit + felis. Integer egestas risus purus, ac rhoncus orci faucibus ac. + Pellentesque iaculis ligula a mauris aliquam, at ullamcorper est + vestibulum. Proin maximus sagittis purus ac pretium. Ut accumsan vitae + nisl sed viverra. + + + + Vivamus malesuada elit facilisis, fringilla lacus non, vulputate felis. + Curabitur dignissim quis ipsum eget pellentesque. Duis efficitur nec nisl + sit amet porta. Maecenas ac dui a felis finibus elementum feugiat at nibh. + Donec convallis sodales neque. Integer id libero eget diam finibus + tincidunt id id diam. Fusce ut lectus nisi. Donec orci enim, semper ac + feugiat vitae, dignissim non enim. Vestibulum commodo dolor nec sem + viverra gravida. Ut laoreet eu tortor auctor consequat. Nulla quis mauris + mollis, aliquam mi nec, laoreet ligula. Fusce laoreet lorem et malesuada + suscipit. Nullam convallis, risus a posuere ultrices, velit augue + porttitor ante, vitae lobortis ligula velit id justo. Praesent nec lorem + massa. + +
    +); + +export default Blurb; diff --git a/examples/global-services/src/components/sheet/Sheet.tsx b/examples/global-services/src/components/sheet/Sheet.tsx new file mode 100644 index 00000000..d117458f --- /dev/null +++ b/examples/global-services/src/components/sheet/Sheet.tsx @@ -0,0 +1,134 @@ +"use client"; + +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../../lib/cn"; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-[29.5rem]", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-[29.5rem]", + }, + }, + defaultVariants: { + side: "right", + }, + }, +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +); +SheetHeader.displayName = "SheetHeader"; + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +); +SheetFooter.displayName = "SheetFooter"; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/examples/global-services/src/components/shimmer.tsx b/examples/global-services/src/components/shimmer.tsx new file mode 100644 index 00000000..208e3f11 --- /dev/null +++ b/examples/global-services/src/components/shimmer.tsx @@ -0,0 +1,13 @@ +export const shimmer = (w: number, h: number) => ` + + + + + + + + + + + +`; diff --git a/examples/global-services/src/components/skeleton/Skeleton.tsx b/examples/global-services/src/components/skeleton/Skeleton.tsx new file mode 100644 index 00000000..788edc09 --- /dev/null +++ b/examples/global-services/src/components/skeleton/Skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "../../lib/cn"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
    + ); +} + +export { Skeleton }; diff --git a/examples/global-services/src/components/toast/toaster.tsx b/examples/global-services/src/components/toast/toaster.tsx new file mode 100644 index 00000000..758c60d7 --- /dev/null +++ b/examples/global-services/src/components/toast/toaster.tsx @@ -0,0 +1,23 @@ +"use client"; +import { useEffect } from "react"; +import { useEvent } from "@elasticpath/react-shopper-hooks"; +import { ToastContainer, toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; + +export function Toaster(): JSX.Element { + const { events } = useEvent(); + + useEffect(() => { + const sub = events.subscribe((event) => { + const toastFn = event.type === "success" ? toast.success : toast.error; + toastFn(`${"message" in event ? event.message : undefined}`, { + position: "bottom-center", + autoClose: 3000, + hideProgressBar: true, + }); + }); + return () => sub.unsubscribe(); + }, [events]); + + return ; +} diff --git a/examples/global-services/src/hooks/use-countries.tsx b/examples/global-services/src/hooks/use-countries.tsx new file mode 100644 index 00000000..0f836739 --- /dev/null +++ b/examples/global-services/src/hooks/use-countries.tsx @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { countries } from "../lib/all-countries"; + +export function useCountries() { + const storeCountries = useQuery({ + queryKey: ["countries"], + queryFn: () => { + /** + * Replace these with your own source for supported delivery countries. You can also fetch them from the API. + */ + return countries; + }, + }); + + return { + ...storeCountries, + }; +} diff --git a/examples/global-services/src/lib/all-countries.ts b/examples/global-services/src/lib/all-countries.ts new file mode 100644 index 00000000..0a772b3d --- /dev/null +++ b/examples/global-services/src/lib/all-countries.ts @@ -0,0 +1,258 @@ +export type CountryValue = { + name: string; + code: string; +}; + +export const countries: CountryValue[] = [ + { name: "Albania", code: "AL" }, + { name: "Åland Islands", code: "AX" }, + { name: "Algeria", code: "DZ" }, + { name: "American Samoa", code: "AS" }, + { name: "Andorra", code: "AD" }, + { name: "Angola", code: "AO" }, + { name: "Anguilla", code: "AI" }, + { name: "Antarctica", code: "AQ" }, + { name: "Antigua and Barbuda", code: "AG" }, + { name: "Argentina", code: "AR" }, + { name: "Armenia", code: "AM" }, + { name: "Aruba", code: "AW" }, + { name: "Australia", code: "AU" }, + { name: "Austria", code: "AT" }, + { name: "Azerbaijan", code: "AZ" }, + { name: "Bahamas (the)", code: "BS" }, + { name: "Bahrain", code: "BH" }, + { name: "Bangladesh", code: "BD" }, + { name: "Barbados", code: "BB" }, + { name: "Belarus", code: "BY" }, + { name: "Belgium", code: "BE" }, + { name: "Belize", code: "BZ" }, + { name: "Benin", code: "BJ" }, + { name: "Bermuda", code: "BM" }, + { name: "Bhutan", code: "BT" }, + { name: "Bolivia (Plurinational State of)", code: "BO" }, + { name: "Bonaire, Sint Eustatius and Saba", code: "BQ" }, + { name: "Bosnia and Herzegovina", code: "BA" }, + { name: "Botswana", code: "BW" }, + { name: "Bouvet Island", code: "BV" }, + { name: "Brazil", code: "BR" }, + { name: "British Indian Ocean Territory (the)", code: "IO" }, + { name: "Brunei Darussalam", code: "BN" }, + { name: "Bulgaria", code: "BG" }, + { name: "Burkina Faso", code: "BF" }, + { name: "Burundi", code: "BI" }, + { name: "Cabo Verde", code: "CV" }, + { name: "Cambodia", code: "KH" }, + { name: "Cameroon", code: "CM" }, + { name: "Canada", code: "CA" }, + { name: "Cayman Islands (the)", code: "KY" }, + { name: "Central African Republic (the)", code: "CF" }, + { name: "Chad", code: "TD" }, + { name: "Chile", code: "CL" }, + { name: "China", code: "CN" }, + { name: "Christmas Island", code: "CX" }, + { name: "Cocos (Keeling) Islands (the)", code: "CC" }, + { name: "Colombia", code: "CO" }, + { name: "Comoros (the)", code: "KM" }, + { name: "Congo (the Democratic Republic of the)", code: "CD" }, + { name: "Congo (the)", code: "CG" }, + { name: "Cook Islands (the)", code: "CK" }, + { name: "Costa Rica", code: "CR" }, + { name: "Croatia", code: "HR" }, + { name: "Cuba", code: "CU" }, + { name: "Curaçao", code: "CW" }, + { name: "Cyprus", code: "CY" }, + { name: "Czechia", code: "CZ" }, + { name: "Côte d'Ivoire", code: "CI" }, + { name: "Denmark", code: "DK" }, + { name: "Djibouti", code: "DJ" }, + { name: "Dominica", code: "DM" }, + { name: "Dominican Republic (the)", code: "DO" }, + { name: "Ecuador", code: "EC" }, + { name: "Egypt", code: "EG" }, + { name: "El Salvador", code: "SV" }, + { name: "Equatorial Guinea", code: "GQ" }, + { name: "Eritrea", code: "ER" }, + { name: "Estonia", code: "EE" }, + { name: "Eswatini", code: "SZ" }, + { name: "Ethiopia", code: "ET" }, + { name: "Falkland Islands (the) [Malvinas]", code: "FK" }, + { name: "Faroe Islands (the)", code: "FO" }, + { name: "Fiji", code: "FJ" }, + { name: "Finland", code: "FI" }, + { name: "France", code: "FR" }, + { name: "French Guiana", code: "GF" }, + { name: "French Polynesia", code: "PF" }, + { name: "French Southern Territories (the)", code: "TF" }, + { name: "Gabon", code: "GA" }, + { name: "Gambia (the)", code: "GM" }, + { name: "Georgia", code: "GE" }, + { name: "Germany", code: "DE" }, + { name: "Ghana", code: "GH" }, + { name: "Gibraltar", code: "GI" }, + { name: "Greece", code: "GR" }, + { name: "Greenland", code: "GL" }, + { name: "Grenada", code: "GD" }, + { name: "Guadeloupe", code: "GP" }, + { name: "Guam", code: "GU" }, + { name: "Guatemala", code: "GT" }, + { name: "Guernsey", code: "GG" }, + { name: "Guinea", code: "GN" }, + { name: "Guinea-Bissau", code: "GW" }, + { name: "Guyana", code: "GY" }, + { name: "Haiti", code: "HT" }, + { name: "Heard Island and McDonald Islands", code: "HM" }, + { name: "Holy See (the)", code: "VA" }, + { name: "Honduras", code: "HN" }, + { name: "Hong Kong", code: "HK" }, + { name: "Hungary", code: "HU" }, + { name: "Iceland", code: "IS" }, + { name: "India", code: "IN" }, + { name: "Indonesia", code: "ID" }, + { name: "Iran (Islamic Republic of)", code: "IR" }, + { name: "Iraq", code: "IQ" }, + { name: "Ireland", code: "IE" }, + { name: "Isle of Man", code: "IM" }, + { name: "Israel", code: "IL" }, + { name: "Italy", code: "IT" }, + { name: "Jamaica", code: "JM" }, + { name: "Japan", code: "JP" }, + { name: "Jersey", code: "JE" }, + { name: "Jordan", code: "JO" }, + { name: "Kazakhstan", code: "KZ" }, + { name: "Kenya", code: "KE" }, + { name: "Kiribati", code: "KI" }, + { name: "Korea (the Democratic People's Republic of)", code: "KP" }, + { name: "Korea (the Republic of)", code: "KR" }, + { name: "Kuwait", code: "KW" }, + { name: "Kyrgyzstan", code: "KG" }, + { name: "Lao People's Democratic Republic (the)", code: "LA" }, + { name: "Latvia", code: "LV" }, + { name: "Lebanon", code: "LB" }, + { name: "Lesotho", code: "LS" }, + { name: "Liberia", code: "LR" }, + { name: "Libya", code: "LY" }, + { name: "Liechtenstein", code: "LI" }, + { name: "Lithuania", code: "LT" }, + { name: "Luxembourg", code: "LU" }, + { name: "Macao", code: "MO" }, + { name: "Madagascar", code: "MG" }, + { name: "Malawi", code: "MW" }, + { name: "Malaysia", code: "MY" }, + { name: "Maldives", code: "MV" }, + { name: "Mali", code: "ML" }, + { name: "Malta", code: "MT" }, + { name: "Marshall Islands (the)", code: "MH" }, + { name: "Martinique", code: "MQ" }, + { name: "Mauritania", code: "MR" }, + { name: "Mauritius", code: "MU" }, + { name: "Mayotte", code: "YT" }, + { name: "Mexico", code: "MX" }, + { name: "Micronesia (Federated States of)", code: "FM" }, + { name: "Moldova (the Republic of)", code: "MD" }, + { name: "Monaco", code: "MC" }, + { name: "Mongolia", code: "MN" }, + { name: "Montenegro", code: "ME" }, + { name: "Montserrat", code: "MS" }, + { name: "Morocco", code: "MA" }, + { name: "Mozambique", code: "MZ" }, + { name: "Myanmar", code: "MM" }, + { name: "Namibia", code: "NA" }, + { name: "Nauru", code: "NR" }, + { name: "Nepal", code: "NP" }, + { name: "Netherlands (the)", code: "NL" }, + { name: "New Caledonia", code: "NC" }, + { name: "New Zealand", code: "NZ" }, + { name: "Nicaragua", code: "NI" }, + { name: "Niger (the)", code: "NE" }, + { name: "Nigeria", code: "NG" }, + { name: "Niue", code: "NU" }, + { name: "Norfolk Island", code: "NF" }, + { name: "Northern Mariana Islands (the)", code: "MP" }, + { name: "Norway", code: "NO" }, + { name: "Oman", code: "OM" }, + { name: "Pakistan", code: "PK" }, + { name: "Palau", code: "PW" }, + { name: "Palestine, State of", code: "PS" }, + { name: "Panama", code: "PA" }, + { name: "Papua New Guinea", code: "PG" }, + { name: "Paraguay", code: "PY" }, + { name: "Peru", code: "PE" }, + { name: "Philippines (the)", code: "PH" }, + { name: "Pitcairn", code: "PN" }, + { name: "Poland", code: "PL" }, + { name: "Portugal", code: "PT" }, + { name: "Puerto Rico", code: "PR" }, + { name: "Qatar", code: "QA" }, + { name: "Republic of North Macedonia", code: "MK" }, + { name: "Romania", code: "RO" }, + { name: "Russian Federation (the)", code: "RU" }, + { name: "Rwanda", code: "RW" }, + { name: "Réunion", code: "RE" }, + { name: "Saint Barthélemy", code: "BL" }, + { name: "Saint Helena, Ascension and Tristan da Cunha", code: "SH" }, + { name: "Saint Kitts and Nevis", code: "KN" }, + { name: "Saint Lucia", code: "LC" }, + { name: "Saint Martin (French part)", code: "MF" }, + { name: "Saint Pierre and Miquelon", code: "PM" }, + { name: "Saint Vincent and the Grenadines", code: "VC" }, + { name: "Samoa", code: "WS" }, + { name: "San Marino", code: "SM" }, + { name: "Sao Tome and Principe", code: "ST" }, + { name: "Saudi Arabia", code: "SA" }, + { name: "Senegal", code: "SN" }, + { name: "Serbia", code: "RS" }, + { name: "Seychelles", code: "SC" }, + { name: "Sierra Leone", code: "SL" }, + { name: "Singapore", code: "SG" }, + { name: "Sint Maarten (Dutch part)", code: "SX" }, + { name: "Slovakia", code: "SK" }, + { name: "Slovenia", code: "SI" }, + { name: "Solomon Islands", code: "SB" }, + { name: "Somalia", code: "SO" }, + { name: "South Africa", code: "ZA" }, + { name: "South Georgia and the South Sandwich Islands", code: "GS" }, + { name: "South Sudan", code: "SS" }, + { name: "Spain", code: "ES" }, + { name: "Sri Lanka", code: "LK" }, + { name: "Sudan (the)", code: "SD" }, + { name: "Suriname", code: "SR" }, + { name: "Svalbard and Jan Mayen", code: "SJ" }, + { name: "Sweden", code: "SE" }, + { name: "Switzerland", code: "CH" }, + { name: "Syrian Arab Republic", code: "SY" }, + { name: "Taiwan (Province of China)", code: "TW" }, + { name: "Tajikistan", code: "TJ" }, + { name: "Tanzania, United Republic of", code: "TZ" }, + { name: "Thailand", code: "TH" }, + { name: "Timor-Leste", code: "TL" }, + { name: "Togo", code: "TG" }, + { name: "Tokelau", code: "TK" }, + { name: "Tonga", code: "TO" }, + { name: "Trinidad and Tobago", code: "TT" }, + { name: "Tunisia", code: "TN" }, + { name: "Turkey", code: "TR" }, + { name: "Turkmenistan", code: "TM" }, + { name: "Turks and Caicos Islands (the)", code: "TC" }, + { name: "Tuvalu", code: "TV" }, + { name: "Uganda", code: "UG" }, + { name: "Ukraine", code: "UA" }, + { name: "United Arab Emirates (the)", code: "AE" }, + { + name: "United Kingdom of Great Britain and Northern Ireland (the)", + code: "GB", + }, + { name: "United States Minor Outlying Islands (the)", code: "UM" }, + { name: "United States of America (the)", code: "US" }, + { name: "Uruguay", code: "UY" }, + { name: "Uzbekistan", code: "UZ" }, + { name: "Vanuatu", code: "VU" }, + { name: "Venezuela (Bolivarian Republic of)", code: "VE" }, + { name: "Viet Nam", code: "VN" }, + { name: "Virgin Islands (British)", code: "VG" }, + { name: "Virgin Islands (U.S.)", code: "VI" }, + { name: "Wallis and Futuna", code: "WF" }, + { name: "Western Sahara", code: "EH" }, + { name: "Yemen", code: "YE" }, + { name: "Zambia", code: "ZM" }, + { name: "Zimbabwe", code: "ZW" }, +]; diff --git a/examples/global-services/src/lib/build-breadcrumb-lookup.ts b/examples/global-services/src/lib/build-breadcrumb-lookup.ts new file mode 100644 index 00000000..4d170da9 --- /dev/null +++ b/examples/global-services/src/lib/build-breadcrumb-lookup.ts @@ -0,0 +1,19 @@ +import { NavigationNode } from "./build-site-navigation"; +import { BreadcrumbLookup } from "./types/breadcrumb-lookup"; + +export function buildBreadcrumbLookup( + nodes: NavigationNode[], +): BreadcrumbLookup { + return nodes.reduce((acc, curr) => { + const { href, name, children, slug } = curr; + return { + ...acc, + [href]: { + href, + name, + slug, + }, + ...(children && buildBreadcrumbLookup(children)), + }; + }, {}); +} diff --git a/examples/global-services/src/lib/build-site-navigation.ts b/examples/global-services/src/lib/build-site-navigation.ts new file mode 100644 index 00000000..d50c7f34 --- /dev/null +++ b/examples/global-services/src/lib/build-site-navigation.ts @@ -0,0 +1,104 @@ +import type { Hierarchy,ElasticPath } from "@elasticpath/js-sdk"; +import { + getHierarchies, + getHierarchyChildren, + getHierarchyNodes, +} from "../services/hierarchy"; + +interface ISchema { + name: string; + slug: string; + href: string; + id: string; + children: ISchema[]; +} + +export interface NavigationNode { + name: string; + slug: string; + href: string; + id: string; + children: NavigationNode[]; +} + +export async function buildSiteNavigation( + client: ElasticPath, +): Promise { + // Fetch hierarchies to be used as top level nav + const hierarchies = await getHierarchies(client); + return constructTree(hierarchies, client); +} + +/** + * Construct hierarchy tree, limited to 5 hierarchies at the top level + */ +function constructTree( + hierarchies: Hierarchy[], + client: ElasticPath, +): Promise { + const tree = hierarchies + .slice(0, 4) + .map((hierarchy) => + createNode({ + name: hierarchy.attributes.name, + id: hierarchy.id, + slug: hierarchy.attributes.slug, + }), + ) + .map(async (hierarchy) => { + // Fetch first-level nav ('parent nodes') - the direct children of each hierarchy + const directChildren = await getHierarchyChildren(hierarchy.id, client); + // Fetch all nodes in each hierarchy (i.e. all 'child nodes' belonging to a hierarchy) + const allNodes = await getHierarchyNodes(hierarchy.id, client); + + // Build 2nd level by finding all 'child nodes' belonging to each first level featured-nodes + const directs = directChildren.slice(0, 4).map((child) => { + const children: ISchema[] = allNodes + .filter((node) => node?.relationships?.parent.data.id === child.id) + .map((node) => + createNode({ + name: node.attributes.name, + id: node.id, + slug: node.attributes.slug, + hrefBase: `${hierarchy.href}/${child.attributes.slug}`, + }), + ); + + return createNode({ + name: child.attributes.name, + id: child.id, + slug: child.attributes.slug, + hrefBase: hierarchy.href, + children, + }); + }); + + return { ...hierarchy, children: directs }; + }); + + return Promise.all(tree); +} + +interface CreateNodeDefinition { + name: string; + id: string; + slug?: string; + hrefBase?: string; + children?: ISchema[]; +} + +function createNode({ + name, + id, + slug = "missing-slug", + hrefBase = "", + children = [], +}: CreateNodeDefinition): ISchema { + return { + name, + id, + slug, + href: `${hrefBase}/${slug}`, + children, + }; +} diff --git a/examples/global-services/src/lib/cart-cookie-server.ts b/examples/global-services/src/lib/cart-cookie-server.ts new file mode 100644 index 00000000..86bf8f6f --- /dev/null +++ b/examples/global-services/src/lib/cart-cookie-server.ts @@ -0,0 +1,18 @@ +import "server-only"; +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; +import { cookies } from "next/headers"; + +const CART_COOKIE_NAME = `${COOKIE_PREFIX_KEY}_ep_cart`; + +/** + * The cart cookie is set by nextjs middleware. + */ +export function getCartCookieServer(): string { + const possibleCartCookie = cookies().get(CART_COOKIE_NAME); + + if (!possibleCartCookie) { + throw Error(`Failed to fetch cart cookie! key ${CART_COOKIE_NAME}`); + } + + return possibleCartCookie.value; +} diff --git a/examples/global-services/src/lib/cn.tsx b/examples/global-services/src/lib/cn.tsx new file mode 100644 index 00000000..a5ef1935 --- /dev/null +++ b/examples/global-services/src/lib/cn.tsx @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/examples/global-services/src/lib/color-lookup.ts b/examples/global-services/src/lib/color-lookup.ts new file mode 100644 index 00000000..9c147119 --- /dev/null +++ b/examples/global-services/src/lib/color-lookup.ts @@ -0,0 +1,10 @@ +export const colorLookup: { [key: string]: string } = { + gray: "gray", + grey: "gray", + red: "red", + white: "white", + teal: "teal", + purple: "purple", + green: "green", + blue: "blue", +}; diff --git a/examples/global-services/src/lib/connect-products-with-main-images.ts b/examples/global-services/src/lib/connect-products-with-main-images.ts new file mode 100644 index 00000000..f1976000 --- /dev/null +++ b/examples/global-services/src/lib/connect-products-with-main-images.ts @@ -0,0 +1,29 @@ +import { File, ProductResponse } from "@elasticpath/js-sdk"; +import { + ProductImageObject, + ProductResponseWithImage, +} from "./types/product-types"; + +export const connectProductsWithMainImages = ( + products: ProductResponse[], + images: File[], +): ProductResponseWithImage[] => { + // Object with image id as a key and File data as a value + let imagesObject: ProductImageObject = {}; + images.forEach((image) => { + imagesObject[image.id] = image; + }); + + const productList: ProductResponseWithImage[] = [...products]; + + productList.forEach((product) => { + if ( + product.relationships.main_image?.data && + imagesObject[product.relationships.main_image.data?.id] + ) { + product.main_image = + imagesObject[product.relationships.main_image.data?.id]; + } + }); + return productList; +}; diff --git a/examples/global-services/src/lib/constants.ts b/examples/global-services/src/lib/constants.ts new file mode 100644 index 00000000..eaaf64a1 --- /dev/null +++ b/examples/global-services/src/lib/constants.ts @@ -0,0 +1,6 @@ +export const DEFAULT_PAGINATION_LIMIT = 25; +export const TAGS = { + cart: "cart", + products: "products", + nodes: "nodes", +}; diff --git a/examples/global-services/src/lib/cookie-constants.ts b/examples/global-services/src/lib/cookie-constants.ts new file mode 100644 index 00000000..62392d7f --- /dev/null +++ b/examples/global-services/src/lib/cookie-constants.ts @@ -0,0 +1,4 @@ +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; + +export const CREDENTIALS_COOKIE_NAME = `${COOKIE_PREFIX_KEY}_ep_credentials`; +export const ACCOUNT_MEMBER_TOKEN_COOKIE_NAME = `${COOKIE_PREFIX_KEY}_ep_account_member_token`; diff --git a/examples/global-services/src/lib/create-breadcrumb.ts b/examples/global-services/src/lib/create-breadcrumb.ts new file mode 100644 index 00000000..f3c1c8e2 --- /dev/null +++ b/examples/global-services/src/lib/create-breadcrumb.ts @@ -0,0 +1,31 @@ +import { BreadcrumbLookup } from "./types/breadcrumb-lookup"; + +export interface BreadcrumbEntry { + value: string; + breadcrumb: string; + label: string; +} + +export function createBreadcrumb( + [head, ...tail]: string[], + lookup?: BreadcrumbLookup, + acc: BreadcrumbEntry[] = [], + breadcrumb?: string, +): BreadcrumbEntry[] { + const updatedBreadcrumb = `${breadcrumb ? `${breadcrumb}/` : ""}${head}`; + + const label = lookup?.[`/${updatedBreadcrumb}`]?.name ?? head; + + const entry = { + value: head, + breadcrumb: updatedBreadcrumb, + label, + }; + if (!head) { + return []; + } + if (tail.length < 1) { + return [...acc, entry]; + } + return createBreadcrumb(tail, lookup, [...acc, entry], updatedBreadcrumb); +} diff --git a/examples/global-services/src/lib/custom-rule-headers.ts b/examples/global-services/src/lib/custom-rule-headers.ts new file mode 100644 index 00000000..b320e365 --- /dev/null +++ b/examples/global-services/src/lib/custom-rule-headers.ts @@ -0,0 +1,17 @@ +import { isEmptyObj } from "./is-empty-object"; + +export function resolveEpccCustomRuleHeaders(): + | { "EP-Context-Tag"?: string; "EP-Channel"?: string } + | undefined { + const { epContextTag, epChannel } = { + epContextTag: process.env.NEXT_PUBLIC_CONTEXT_TAG, + epChannel: process.env.NEXT_PUBLIC_CHANNEL, + }; + + const headers = { + ...(epContextTag ? { "EP-Context-Tag": epContextTag } : {}), + ...(epChannel ? { "EP-Channel": epChannel } : {}), + }; + + return isEmptyObj(headers) ? undefined : headers; +} diff --git a/examples/global-services/src/lib/epcc-errors.ts b/examples/global-services/src/lib/epcc-errors.ts new file mode 100644 index 00000000..6f1f58c5 --- /dev/null +++ b/examples/global-services/src/lib/epcc-errors.ts @@ -0,0 +1,27 @@ +export function isNoDefaultCatalogError( + errors: object[], +): errors is [{ detail: string }] { + const error = errors[0]; + return ( + hasDetail(error) && + error.detail === + "unable to resolve default catalog: no default catalog id can be identified: not found" + ); +} + +function hasDetail(err: object): err is { detail: string } { + return "detail" in err; +} + +export function isEPError(err: unknown): err is { errors: object[] } { + return ( + typeof err === "object" && + !!err && + hasErrors(err) && + Array.isArray(err.errors) + ); +} + +function hasErrors(err: object): err is { errors: object[] } { + return "errors" in err; +} diff --git a/examples/global-services/src/lib/epcc-implicit-client.ts b/examples/global-services/src/lib/epcc-implicit-client.ts new file mode 100644 index 00000000..0067d84b --- /dev/null +++ b/examples/global-services/src/lib/epcc-implicit-client.ts @@ -0,0 +1,37 @@ +import { gateway, StorageFactory } from "@elasticpath/js-sdk"; +import { epccEnv } from "./resolve-epcc-env"; +import { resolveEpccCustomRuleHeaders } from "./custom-rule-headers"; +import { deleteCookie, getCookie, setCookie } from "cookies-next"; +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; +import { EP_CURRENCY_CODE } from "./resolve-ep-currency-code"; + +const headers = resolveEpccCustomRuleHeaders(); + +const { client_id, host } = epccEnv; + +export function getEpccImplicitClient() { + return gateway({ + name: COOKIE_PREFIX_KEY, + client_id, + host, + currency: EP_CURRENCY_CODE, + ...(headers ? { headers } : {}), + storage: createNextCookieStorageFactory(), + }); +} + +function createNextCookieStorageFactory(): StorageFactory { + return { + set: (key: string, value: string): void => { + setCookie(key, value, { + sameSite: "strict", + }); + }, + get: (key: string): any => { + return getCookie(key); + }, + delete: (key: string) => { + deleteCookie(key); + }, + }; +} diff --git a/examples/global-services/src/lib/epcc-server-client.ts b/examples/global-services/src/lib/epcc-server-client.ts new file mode 100644 index 00000000..4c59b599 --- /dev/null +++ b/examples/global-services/src/lib/epcc-server-client.ts @@ -0,0 +1,29 @@ +import { + ConfigOptions, + gateway as EPCCGateway, + MemoryStorageFactory, +} from "@elasticpath/js-sdk"; +import { epccEnv } from "./resolve-epcc-env"; +import { resolveEpccCustomRuleHeaders } from "./custom-rule-headers"; +import { EP_CURRENCY_CODE } from "./resolve-ep-currency-code"; + +const headers = resolveEpccCustomRuleHeaders(); + +const { client_id, client_secret, host } = epccEnv; + +if (typeof client_secret !== "string") { + throw Error( + "Attempted to use client credentials client without a defined client_secret. This is most likely caused by trying to use server side client on the client side.", + ); +} + +const config: ConfigOptions = { + client_id, + client_secret, + host, + currency: EP_CURRENCY_CODE, + storage: new MemoryStorageFactory(), + ...(headers ? { headers } : {}), +}; + +export const epccServerClient = EPCCGateway(config); diff --git a/examples/global-services/src/lib/epcc-server-side-credentials-client.ts b/examples/global-services/src/lib/epcc-server-side-credentials-client.ts new file mode 100644 index 00000000..13ca16ef --- /dev/null +++ b/examples/global-services/src/lib/epcc-server-side-credentials-client.ts @@ -0,0 +1,49 @@ +import "server-only"; +import { gateway, StorageFactory } from "@elasticpath/js-sdk"; +import { epccEnv } from "./resolve-epcc-env"; +import { resolveEpccCustomRuleHeaders } from "./custom-rule-headers"; +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; +import { EP_CURRENCY_CODE } from "./resolve-ep-currency-code"; +import { CREDENTIALS_COOKIE_NAME } from "./cookie-constants"; +import { cookies } from "next/headers"; + +const customHeaders = resolveEpccCustomRuleHeaders(); + +const { client_id, host, client_secret } = epccEnv; + +export function getServerSideCredentialsClient() { + const credentialsCookie = cookies().get(CREDENTIALS_COOKIE_NAME); + + return gateway({ + name: `${COOKIE_PREFIX_KEY}_creds`, + client_id, + client_secret, + host, + currency: EP_CURRENCY_CODE, + ...(customHeaders ? { headers: customHeaders } : {}), + reauth: false, + storage: createServerSideNextCookieStorageFactory(credentialsCookie?.value), + }); +} + +function createServerSideNextCookieStorageFactory( + initialCookieValue?: string, +): StorageFactory { + let state = new Map(); + + if (initialCookieValue) { + state.set(`${COOKIE_PREFIX_KEY}_ep_credentials`, initialCookieValue); + } + + return { + set: (key: string, value: string): void => { + state.set(key, value); + }, + get: (key: string): any => { + return state.get(key); + }, + delete: (key: string) => { + state.delete(key); + }, + }; +} diff --git a/examples/global-services/src/lib/epcc-server-side-implicit-client.ts b/examples/global-services/src/lib/epcc-server-side-implicit-client.ts new file mode 100644 index 00000000..dfba3600 --- /dev/null +++ b/examples/global-services/src/lib/epcc-server-side-implicit-client.ts @@ -0,0 +1,48 @@ +import "server-only"; +import { gateway, StorageFactory } from "@elasticpath/js-sdk"; +import { epccEnv } from "./resolve-epcc-env"; +import { resolveEpccCustomRuleHeaders } from "./custom-rule-headers"; +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; +import { EP_CURRENCY_CODE } from "./resolve-ep-currency-code"; +import { CREDENTIALS_COOKIE_NAME } from "./cookie-constants"; +import { cookies } from "next/headers"; + +const customHeaders = resolveEpccCustomRuleHeaders(); + +const { client_id, host } = epccEnv; + +export function getServerSideImplicitClient() { + const credentialsCookie = cookies().get(CREDENTIALS_COOKIE_NAME); + + return gateway({ + name: COOKIE_PREFIX_KEY, + client_id, + host, + currency: EP_CURRENCY_CODE, + ...(customHeaders ? { headers: customHeaders } : {}), + reauth: false, + storage: createServerSideNextCookieStorageFactory(credentialsCookie?.value), + }); +} + +function createServerSideNextCookieStorageFactory( + initialCookieValue?: string, +): StorageFactory { + let state = new Map(); + + if (initialCookieValue) { + state.set(`${COOKIE_PREFIX_KEY}_ep_credentials`, initialCookieValue); + } + + return { + set: (key: string, value: string): void => { + state.set(key, value); + }, + get: (key: string): any => { + return state.get(key); + }, + delete: (key: string) => { + state.delete(key); + }, + }; +} diff --git a/examples/global-services/src/lib/file-lookup.test.ts b/examples/global-services/src/lib/file-lookup.test.ts new file mode 100644 index 00000000..e5874188 --- /dev/null +++ b/examples/global-services/src/lib/file-lookup.test.ts @@ -0,0 +1,204 @@ +import { describe, test, expect } from "vitest"; +import { ProductResponse, File } from "@elasticpath/js-sdk"; +import { + getMainImageForProductResponse, + getOtherImagesForProductResponse, +} from "./file-lookup"; + +describe("file-lookup", () => { + test("getImagesForProductResponse should return the correct image file object", () => { + const productResp = { + id: "944cef1a-c906-4efc-b920-d2c489ec6181", + relationships: { + files: { + data: [ + { + created_at: "2022-05-27T08:16:58.110Z", + id: "0de087d5-253b-4f10-8a09-0c10ffd6e7fa", + type: "file", + }, + { + created_at: "2022-05-27T08:16:58.110Z", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + type: "file", + }, + { + created_at: "2023-10-28T14:19:20.832Z", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + type: "file", + }, + ], + }, + main_image: { + data: { + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + type: "main_image", + }, + }, + parent: { + data: { + id: "2f435914-03b5-4b9e-80cb-08d3baa4c1d3", + type: "product", + }, + }, + }, + } as Partial; + + const mainImage: Partial[] = [ + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + { + type: "file", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/e5b9fed7-fcef-44d7-9ab1-3b4a277baf21.jpg", + }, + }, + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + ]; + + expect( + getMainImageForProductResponse( + productResp as ProductResponse, + mainImage as File[], + ), + ).toEqual({ + type: "file", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/e5b9fed7-fcef-44d7-9ab1-3b4a277baf21.jpg", + }, + }); + }); + + test("getOtherImagesForProductResponse should return other images for product", () => { + const productResp = { + id: "944cef1a-c906-4efc-b920-d2c489ec6181", + relationships: { + files: { + data: [ + { + created_at: "2022-05-27T08:16:58.110Z", + id: "0de087d5-253b-4f10-8a09-0c10ffd6e7fa", + type: "file", + }, + { + created_at: "2022-05-27T08:16:58.110Z", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + type: "file", + }, + { + created_at: "2023-10-28T14:19:20.832Z", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + type: "file", + }, + ], + }, + main_image: { + data: { + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + type: "main_image", + }, + }, + parent: { + data: { + id: "2f435914-03b5-4b9e-80cb-08d3baa4c1d3", + type: "product", + }, + }, + }, + } as Partial; + + const files = [ + { + type: "file", + id: "0de087d5-253b-4f10-8a09-0c10ffd6e7fa", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/0de087d5-253b-4f10-8a09-0c10ffd6e7fa.jpeg", + }, + }, + { + type: "file", + id: "0de087d5-253b-4f10-8a09-0c10ffd6e7fa", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/0de087d5-253b-4f10-8a09-0c10ffd6e7fa.jpeg", + }, + }, + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + { + type: "file", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/e5b9fed7-fcef-44d7-9ab1-3b4a277baf21.jpg", + }, + }, + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + { + type: "file", + id: "d402c7e2-c8e9-46bc-93f4-30955cd0b9ec", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/d402c7e2-c8e9-46bc-93f4-30955cd0b9ec.jpg", + }, + }, + ] as Partial[]; + + expect( + getOtherImagesForProductResponse( + productResp as ProductResponse, + files as File[], + ), + ).toEqual([ + { + type: "file", + id: "0de087d5-253b-4f10-8a09-0c10ffd6e7fa", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/0de087d5-253b-4f10-8a09-0c10ffd6e7fa.jpeg", + }, + }, + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + { + type: "file", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/e5b9fed7-fcef-44d7-9ab1-3b4a277baf21.jpg", + }, + }, + ]); + }); +}); diff --git a/examples/global-services/src/lib/file-lookup.ts b/examples/global-services/src/lib/file-lookup.ts new file mode 100644 index 00000000..eb46afa4 --- /dev/null +++ b/examples/global-services/src/lib/file-lookup.ts @@ -0,0 +1,39 @@ +import { File, ProductResponse } from "@elasticpath/js-sdk"; + +export function getMainImageForProductResponse( + productResponse: ProductResponse, + mainImages: File[], +): File | undefined { + const mainImageId = productResponse.relationships?.main_image?.data?.id; + + if (!mainImageId) { + return; + } + + return lookupFileUsingId(mainImageId, mainImages); +} + +export function getOtherImagesForProductResponse( + productResponse: ProductResponse, + allFiles: File[], +): File[] | undefined { + const productFilesIdObj = productResponse.relationships?.files?.data ?? []; + + if (productFilesIdObj?.length === 0) { + return; + } + + return productFilesIdObj.reduce((acc, fileIdObj) => { + const file = lookupFileUsingId(fileIdObj.id, allFiles); + return [...acc, ...(file ? [file] : [])]; + }, [] as File[]); +} + +export function lookupFileUsingId( + fileId: string, + files: File[], +): File | undefined { + return files.find((file) => { + return file.id === fileId; + }); +} diff --git a/examples/global-services/src/lib/form-url-encode-body.ts b/examples/global-services/src/lib/form-url-encode-body.ts new file mode 100644 index 00000000..b05c4711 --- /dev/null +++ b/examples/global-services/src/lib/form-url-encode-body.ts @@ -0,0 +1,10 @@ +export function formUrlEncodeBody(body: Record): string { + return Object.keys(body) + .map( + (k) => + `${encodeURIComponent(k)}=${encodeURIComponent( + body[k as keyof typeof body], + )}`, + ) + .join("&"); +} diff --git a/examples/global-services/src/lib/format-currency.tsx b/examples/global-services/src/lib/format-currency.tsx new file mode 100644 index 00000000..fcdb76ba --- /dev/null +++ b/examples/global-services/src/lib/format-currency.tsx @@ -0,0 +1,20 @@ +import { Currency } from "@elasticpath/js-sdk"; + +export function formatCurrency( + amount: number, + currency: Currency, + options: { + locals?: Parameters[0]; + } = { locals: "en-US" }, +) { + const { decimal_places, code } = currency; + + const resolvedAmount = amount / Math.pow(10, decimal_places); + + return new Intl.NumberFormat(options.locals, { + style: "currency", + maximumFractionDigits: decimal_places, + minimumFractionDigits: decimal_places, + currency: code, + }).format(resolvedAmount); +} diff --git a/examples/global-services/src/lib/format-iso-date-string.ts b/examples/global-services/src/lib/format-iso-date-string.ts new file mode 100644 index 00000000..ef916c23 --- /dev/null +++ b/examples/global-services/src/lib/format-iso-date-string.ts @@ -0,0 +1,8 @@ +export function formatIsoDateString(isoString: string): string { + const dateObject = new Date(isoString); + return dateObject.toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} diff --git a/examples/global-services/src/lib/get-error-message.ts b/examples/global-services/src/lib/get-error-message.ts new file mode 100644 index 00000000..0d88bc36 --- /dev/null +++ b/examples/global-services/src/lib/get-error-message.ts @@ -0,0 +1,15 @@ +export function getErrorMessage(error: unknown): string { + let message: string; + + if (error instanceof Error) { + message = error.message; + } else if (error && typeof error === "object" && "message" in error) { + message = String(error.message); + } else if (typeof error === "string") { + message = error; + } else { + message = "Something went wrong."; + } + + return message; +} diff --git a/examples/global-services/src/lib/get-store-initial-state.ts b/examples/global-services/src/lib/get-store-initial-state.ts new file mode 100644 index 00000000..dafe837b --- /dev/null +++ b/examples/global-services/src/lib/get-store-initial-state.ts @@ -0,0 +1,20 @@ +import { ElasticPath } from "@elasticpath/js-sdk"; +import { InitialState } from "@elasticpath/react-shopper-hooks"; +import { buildSiteNavigation } from "./build-site-navigation"; +import { getCartCookieServer } from "./cart-cookie-server"; +import { getCart } from "../services/cart"; + +export async function getStoreInitialState( + client: ElasticPath, +): Promise { + const nav = await buildSiteNavigation(client); + + const cartCookie = getCartCookieServer(); + + const cart = await getCart(cartCookie, client); + + return { + cart, + nav, + }; +} diff --git a/examples/global-services/src/lib/is-account-member-authenticated.ts b/examples/global-services/src/lib/is-account-member-authenticated.ts new file mode 100644 index 00000000..ef12009b --- /dev/null +++ b/examples/global-services/src/lib/is-account-member-authenticated.ts @@ -0,0 +1,13 @@ +import type { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "./cookie-constants"; +import { parseAccountMemberCredentialsCookieStr } from "./retrieve-account-member-credentials"; + +export function isAccountMemberAuthenticated( + cookieStore: ReturnType, +): boolean { + const cookie = cookieStore.get(ACCOUNT_MEMBER_TOKEN_COOKIE_NAME); + const parsedCredentials = + cookie && parseAccountMemberCredentialsCookieStr(cookie.value); + + return !!parsedCredentials; +} diff --git a/examples/global-services/src/lib/is-empty-object.ts b/examples/global-services/src/lib/is-empty-object.ts new file mode 100644 index 00000000..200e3eb9 --- /dev/null +++ b/examples/global-services/src/lib/is-empty-object.ts @@ -0,0 +1,2 @@ +export const isEmptyObj = (obj: object): boolean => + Object.keys(obj).length === 0; diff --git a/examples/global-services/src/lib/is-supported-extension.ts b/examples/global-services/src/lib/is-supported-extension.ts new file mode 100644 index 00000000..a7b1adaf --- /dev/null +++ b/examples/global-services/src/lib/is-supported-extension.ts @@ -0,0 +1,9 @@ +export function isSupportedExtension(value: unknown): boolean { + return ( + typeof value === "boolean" || + typeof value === "number" || + typeof value === "string" || + typeof value === "undefined" || + value === null + ); +} diff --git a/examples/global-services/src/lib/klevu.ts b/examples/global-services/src/lib/klevu.ts new file mode 100644 index 00000000..de7e9692 --- /dev/null +++ b/examples/global-services/src/lib/klevu.ts @@ -0,0 +1,157 @@ +import { ShopperProduct } from "@elasticpath/react-shopper-hooks"; +import { KlevuConfig, KlevuFetch, similarProducts, search, trendingProducts, KlevuEvents, KlevuRecord, KlevuSearchOptions, FilterManager, sendSearchEvent, listFilters, applyFilterWithManager, recentlyViewed, newArrivals, kmcRecommendation, advancedFiltering, KlevuFilterResultOptions } from "@klevu/core"; + +export const initKlevu = () => { + KlevuConfig.init({ + url: `https://${process.env.NEXT_PUBLIC_KLEVU_SEARCH_URL!}/cs/v2/search`, + apiKey: process.env.NEXT_PUBLIC_KLEVU_API_KEY!, + }); + } + + export const fetchSimilarProducts = async (id: string) => { + const res = await KlevuFetch( + similarProducts([id]) + ); + + return res.apiResponse.queryResults; + }; + + export const fetchAllProducts = async () => { + const res = await KlevuFetch( + search( + '*', // Using '*' to match all products + { + typeOfRecords: ['KLEVU_PRODUCT'], + limit: 1000, + offset: 0, + } + ) + ); + + return res.apiResponse.queryResults; + }; + + export const fetchProducts = async (searchSettings: Partial, query?: string, manager?: FilterManager) => { + const selectedCategories = manager && manager.filters[0] ? (manager.filters[0] as KlevuFilterResultOptions).options.filter((option) => option.selected).map((option) => option.value) : []; + const res = await KlevuFetch( + search( + query || '*', + searchSettings, + sendSearchEvent(), + listFilters({ + rangeFilterSettings: [ + { + key: "klevu_price", + minMax: true, + }, + ], + ...(manager ? { filterManager: manager } : {}), + }), + // @ts-ignore + (manager && selectedCategories.length) ? advancedFiltering([ + { + key: "category", + singleSelect: true, + valueOperator: "INCLUDE", + values: selectedCategories, + } + ]) : () => null, + // @ts-ignore + manager ? applyFilterWithManager(manager) : () => null + ) + ); + + return res; + }; + + export const fetchFeatureProducts = async () => { + const trendingId = "trending" + new Date().getTime() + + const res = await KlevuFetch( + trendingProducts( + { + limit: 4, + id: trendingId, + }, + ) + ) + return res.queriesById(trendingId); + }; + + export const fetchRecentlyViewed = async () => { + const res = await KlevuFetch( + recentlyViewed() + ); + + return res.apiResponse.queryResults; + }; + + export const fetchNewArrivals = async () => { + const res = await KlevuFetch( + newArrivals() + ); + + return res.apiResponse.queryResults; + }; + + export const fetchKMCRecommendations = async (id: string) => { + const res = await KlevuFetch( + kmcRecommendation(id) + ); + + return res.apiResponse.queryResults; + }; + + export const sendClickEv = async(product: any) => { + KlevuEvents.searchProductClick({ + product, + searchTerm: undefined, + }) + } + + export const transformRecord = (record: KlevuRecord): ShopperProduct => { + const main_image: any = { + link: { + href: record.image + } + }; + + return { + main_image, + response: { + meta: { + display_price: { + without_tax: { + amount: Number(record.price), + formatted: record.price, + currency: record.currency + }, + with_tax: { + amount: Number(record.price), + formatted: record.price, + currency: record.currency + }, + }, + original_display_price: { + without_tax: { + amount: Number(record.salePrice), + formatted: record.price, + currency: record.currency + }, + with_tax: { + amount: Number(record.salePrice), + formatted: record.price, + currency: record.currency + }, + } + }, + attributes: { + name: record.name, + description: record.shortDesc + } as any, + id: record.id + } + } as ShopperProduct + } + + initKlevu(); \ No newline at end of file diff --git a/examples/global-services/src/lib/middleware/apply-set-cookie.ts b/examples/global-services/src/lib/middleware/apply-set-cookie.ts new file mode 100644 index 00000000..f6a1a400 --- /dev/null +++ b/examples/global-services/src/lib/middleware/apply-set-cookie.ts @@ -0,0 +1,31 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { + ResponseCookies, + RequestCookies, +} from "next/dist/server/web/spec-extension/cookies"; + +/** + * Copy cookies from the Set-Cookie header of the response to the Cookie header of the request, + * so that it will appear to SSR/RSC as if the user already has the new cookies. + * + * Workaround for - https://github.com/vercel/next.js/issues/49442#issuecomment-1679807704 + */ +export function applySetCookie(req: NextRequest, res: NextResponse): void { + // parse the outgoing Set-Cookie header + const setCookies = new ResponseCookies(res.headers); + // Build a new Cookie header for the request by adding the setCookies + const newReqHeaders = new Headers(req.headers); + const newReqCookies = new RequestCookies(newReqHeaders); + setCookies.getAll().forEach((cookie) => newReqCookies.set(cookie)); + // set “request header overrides” on the outgoing response + NextResponse.next({ + request: { headers: newReqHeaders }, + }).headers.forEach((value, key) => { + if ( + key === "x-middleware-override-headers" || + key.startsWith("x-middleware-request-") + ) { + res.headers.set(key, value); + } + }); +} diff --git a/examples/global-services/src/lib/middleware/cart-cookie-middleware.ts b/examples/global-services/src/lib/middleware/cart-cookie-middleware.ts new file mode 100644 index 00000000..f0faf431 --- /dev/null +++ b/examples/global-services/src/lib/middleware/cart-cookie-middleware.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + createAuthenticationErrorUrl, + createMissingEnvironmentVariableUrl, +} from "./create-missing-environment-variable-url"; +import { epccEndpoint } from "./implicit-auth-middleware"; +import { NextResponseFlowResult } from "./middleware-runner"; +import { tokenExpired } from "../token-expired"; +import { applySetCookie } from "./apply-set-cookie"; + +const cookiePrefixKey = process.env.NEXT_PUBLIC_COOKIE_PREFIX_KEY; + +export async function cartCookieMiddleware( + req: NextRequest, + previousResponse: NextResponse, +): Promise { + if (typeof cookiePrefixKey !== "string") { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createMissingEnvironmentVariableUrl( + "NEXT_PUBLIC_COOKIE_PREFIX_KEY", + req.nextUrl.basePath, + req.url, + ), + ), + }; + } + + if (req.cookies.get(`${cookiePrefixKey}_ep_cart`)) { + return { + shouldReturn: false, + resultingResponse: previousResponse, + }; + } + + if (typeof epccEndpoint !== "string") { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createMissingEnvironmentVariableUrl( + "NEXT_PUBLIC_EPCC_ENDPOINT_URL", + req.nextUrl.basePath, + req.url, + ), + ), + }; + } + + const authToken = retrieveAuthToken(req, previousResponse); + + if (!authToken) { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createAuthenticationErrorUrl( + `Cart cookie creation failed in middleware because credentials \"${cookiePrefixKey}_ep_credentials\" cookie was missing.`, + req.nextUrl.origin, + req.url, + ), + ), + }; + } + + if (!authToken.access_token) { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createAuthenticationErrorUrl( + `Cart cookie creation failed in middleware because credentials \"access_token\" was undefined.`, + req.nextUrl.origin, + req.url, + ), + ), + }; + } + + const createdCart = await fetch(`https://${epccEndpoint}/v2/carts`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ data: { name: "Cart" } }), + }); + + const parsedCartJSON = await createdCart.json(); + + previousResponse.cookies.set( + `${cookiePrefixKey}_ep_cart`, + parsedCartJSON.data.id, + { + sameSite: "strict", + expires: new Date(parsedCartJSON.data.meta.timestamps.expires_at), + }, + ); + + // Apply those cookies to the request + // Workaround for - https://github.com/vercel/next.js/issues/49442#issuecomment-1679807704 + applySetCookie(req, previousResponse); + + return { + shouldReturn: false, + resultingResponse: previousResponse, + }; +} + +function retrieveAuthToken( + req: NextRequest, + resp: NextResponse, +): { access_token: string; expires: number } | undefined { + const authCookie = + req.cookies.get(`${cookiePrefixKey}_ep_credentials`) ?? + resp.cookies.get(`${cookiePrefixKey}_ep_credentials`); + + const possiblyParsedCookie = authCookie && JSON.parse(authCookie.value); + + return possiblyParsedCookie && tokenExpired(possiblyParsedCookie.expires) + ? undefined + : possiblyParsedCookie; +} diff --git a/examples/global-services/src/lib/middleware/create-missing-environment-variable-url.ts b/examples/global-services/src/lib/middleware/create-missing-environment-variable-url.ts new file mode 100644 index 00000000..f6f347e5 --- /dev/null +++ b/examples/global-services/src/lib/middleware/create-missing-environment-variable-url.ts @@ -0,0 +1,36 @@ +import { NonEmptyArray } from "../types/non-empty-array"; + +export function createMissingEnvironmentVariableUrl( + name: string | NonEmptyArray, + reqUrl: string, + from?: string, +): URL { + const configErrorUrl = createBaseErrorUrl(reqUrl, from); + + (Array.isArray(name) ? name : [name]).forEach((n) => { + configErrorUrl.searchParams.append("missing-env-variable", n); + }); + + return configErrorUrl; +} + +export function createAuthenticationErrorUrl( + message: string, + reqUrl: string, + from?: string, +): URL { + const configErrorUrl = createBaseErrorUrl(reqUrl, from); + configErrorUrl.searchParams.append( + "authentication", + encodeURIComponent(message), + ); + return configErrorUrl; +} + +function createBaseErrorUrl(reqUrl: string, from?: string): URL { + const configErrorUrl = new URL("/configuration-error", reqUrl); + if (from) { + configErrorUrl.searchParams.set("from", from); + } + return configErrorUrl; +} diff --git a/examples/global-services/src/lib/middleware/implicit-auth-middleware.ts b/examples/global-services/src/lib/middleware/implicit-auth-middleware.ts new file mode 100644 index 00000000..22f38d5d --- /dev/null +++ b/examples/global-services/src/lib/middleware/implicit-auth-middleware.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { NextResponseFlowResult } from "./middleware-runner"; +import { formUrlEncodeBody } from "../form-url-encode-body"; +import { + createAuthenticationErrorUrl, + createMissingEnvironmentVariableUrl, +} from "./create-missing-environment-variable-url"; +import { tokenExpired } from "../token-expired"; +import { applySetCookie } from "./apply-set-cookie"; + +export const epccEndpoint = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL; +const clientId = process.env.NEXT_PUBLIC_EPCC_CLIENT_ID; +const cookiePrefixKey = process.env.NEXT_PUBLIC_COOKIE_PREFIX_KEY; + +export async function implicitAuthMiddleware( + req: NextRequest, + previousResponse: NextResponse, +): Promise { + if (typeof clientId !== "string" || typeof cookiePrefixKey !== "string") { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createMissingEnvironmentVariableUrl( + ["NEXT_PUBLIC_EPCC_CLIENT_ID", "NEXT_PUBLIC_COOKIE_PREFIX_KEY"], + req.nextUrl.basePath, + req.url, + ), + ), + }; + } + + const possibleImplicitCookie = req.cookies.get( + `${cookiePrefixKey}_ep_credentials`, + ); + + if ( + possibleImplicitCookie && + !tokenExpired(JSON.parse(possibleImplicitCookie.value).expires) + ) { + return { + shouldReturn: false, + resultingResponse: previousResponse, + }; + } + + const authResponse = await getTokenImplicitToken({ + grant_type: "implicit", + client_id: clientId, + }); + + const token = await authResponse.json(); + + /** + * Check response did not fail + */ + if (token && "errors" in token) { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createAuthenticationErrorUrl( + `Implicit auth middleware failed to get access token.`, + req.nextUrl.origin, + req.url, + ), + ), + }; + } + + previousResponse.cookies.set( + `${cookiePrefixKey}_ep_credentials`, + JSON.stringify({ + ...token, + client_id: clientId, + }), + { + sameSite: "strict", + expires: new Date(token.expires * 1000), + }, + ); + + // Apply those cookies to the request + // Workaround for - https://github.com/vercel/next.js/issues/49442#issuecomment-1679807704 + applySetCookie(req, previousResponse); + + return { + shouldReturn: false, + resultingResponse: previousResponse, + }; +} + +async function getTokenImplicitToken(body: { + grant_type: "implicit"; + client_id: string; +}): Promise { + return fetch(`https://${epccEndpoint}/oauth/access_token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formUrlEncodeBody(body), + }); +} diff --git a/examples/global-services/src/lib/middleware/middleware-runner.ts b/examples/global-services/src/lib/middleware/middleware-runner.ts new file mode 100644 index 00000000..d6b8df45 --- /dev/null +++ b/examples/global-services/src/lib/middleware/middleware-runner.ts @@ -0,0 +1,63 @@ +import { NonEmptyArray } from "../types/non-empty-array"; +import { NextRequest, NextResponse } from "next/server"; + +export interface NextResponseFlowResult { + shouldReturn: boolean; + resultingResponse: NextResponse; +} + +interface RunnableMiddlewareEntryOptions { + exclude?: NonEmptyArray; +} + +interface RunnableMiddlewareEntry { + runnable: RunnableMiddleware; + options?: RunnableMiddlewareEntryOptions; +} + +export function middlewareRunner( + ...middleware: NonEmptyArray +) { + return async (req: NextRequest): Promise => { + let lastResult: NextResponseFlowResult = { + shouldReturn: false, + resultingResponse: NextResponse.next(), + }; + + for (const m of middleware) { + const toRun: RunnableMiddlewareEntry = + "runnable" in m ? m : { runnable: m }; + + const { runnable, options } = toRun; + + if (shouldRun(req.nextUrl.pathname, options?.exclude)) { + lastResult = await runnable(req, lastResult.resultingResponse); + } + + if (lastResult.shouldReturn) { + return lastResult.resultingResponse; + } + } + return lastResult.resultingResponse; + }; +} + +function shouldRun( + pathname: string, + excluded?: NonEmptyArray, +): boolean { + if (excluded) { + for (const path of excluded) { + if (pathname.startsWith(path)) { + return false; + } + } + } + + return true; +} + +type RunnableMiddleware = ( + req: NextRequest, + previousResponse: NextResponse, +) => Promise; diff --git a/examples/global-services/src/lib/product-context.ts b/examples/global-services/src/lib/product-context.ts new file mode 100644 index 00000000..7be48e43 --- /dev/null +++ b/examples/global-services/src/lib/product-context.ts @@ -0,0 +1,10 @@ +import { createContext } from "react"; +import { + ProductContextState, + ProductModalContextState, +} from "./types/product-types"; + +export const ProductContext = createContext(null); + +export const ProductModalContext = + createContext(null); diff --git a/examples/global-services/src/lib/product-helper.test.ts b/examples/global-services/src/lib/product-helper.test.ts new file mode 100644 index 00000000..a71ba895 --- /dev/null +++ b/examples/global-services/src/lib/product-helper.test.ts @@ -0,0 +1,273 @@ +import type { ProductResponse, Variation } from "@elasticpath/js-sdk"; +import { describe, test, expect } from "vitest"; +import { + allVariationsHaveSelectedOption, + getOptionsFromSkuId, + getSkuIdFromOptions, + isChildProductResource, + isSimpleProductResource, + mapOptionsToVariation, +} from "./product-helper"; + +describe("product-helpers", () => { + test("isChildProductResource should return false if it's a base product", () => { + const sampleProduct = { + attributes: { + base_product: true, + }, + } as ProductResponse; + expect(isChildProductResource(sampleProduct)).toEqual(false); + }); + + test("isChildProductResource should return false if it is a simple product", () => { + const sampleProduct = { + attributes: { + base_product: false, + }, + } as ProductResponse; + expect(isChildProductResource(sampleProduct)).toEqual(false); + }); + test("isChildProductResource should return true if it is a child product", () => { + const sampleProduct = { + attributes: { + base_product: false, + base_product_id: "123", + }, + } as ProductResponse; + expect(isChildProductResource(sampleProduct)).toEqual(true); + }); + + test("isSimpleProductResource should return true if it is a simple product", () => { + const sampleProduct = { + attributes: { + base_product: false, + }, + } as ProductResponse; + expect(isSimpleProductResource(sampleProduct)).toEqual(true); + }); + + test("isSimpleProductResource should return false if it is a base product", () => { + const sampleProduct = { + attributes: { + base_product: true, + }, + } as ProductResponse; + expect(isSimpleProductResource(sampleProduct)).toEqual(false); + }); + + test("isSimpleProductResource should return false if it is a child product", () => { + const sampleProduct = { + attributes: { + base_product: true, + base_product_id: "123", + }, + } as ProductResponse; + expect(isSimpleProductResource(sampleProduct)).toEqual(false); + }); + + test("getSkuIDFromOptions should return the id of the sku for the provided options.", () => { + const variationMatrixSample = { + "4252d475-2d0e-4cd2-99d3-19fba34ef211": { + "217883ce-55f1-4c34-8e00-e86c743f4dff": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "709e6cc6-a40c-4833-9469-b4abd0e7f67f", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "c05839f5-3eac-48f2-9d36-1bc2a481a213", + }, + "37b5bcf7-0b65-4e12-ad31-3052e27c107f": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "9e07495c-caf1-4f11-93c5-16cfeb63d492", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "b9bb984a-7a6d-4433-a445-1cde0383bece", + }, + }, + "693b16b8-a3b3-4419-ad03-61007a381c56": { + "217883ce-55f1-4c34-8e00-e86c743f4dff": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "2d864c10-146f-4905-859f-86e63c18abf4", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "42aef769-c97e-48a8-a3c4-2af8ad504ebb", + }, + }, + }; + + const options = [ + "693b16b8-a3b3-4419-ad03-61007a381c56", + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89", + "217883ce-55f1-4c34-8e00-e86c743f4dff", + ]; + + expect(getSkuIdFromOptions(options, variationMatrixSample)).toEqual( + "2d864c10-146f-4905-859f-86e63c18abf4", + ); + }); + test("getSkuIDFromOptions should return undefined when proveded valid but not found options.", () => { + const variationMatrixSample = { + "4252d475-2d0e-4cd2-99d3-19fba34ef211": { + "217883ce-55f1-4c34-8e00-e86c743f4dff": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "709e6cc6-a40c-4833-9469-b4abd0e7f67f", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "c05839f5-3eac-48f2-9d36-1bc2a481a213", + }, + "37b5bcf7-0b65-4e12-ad31-3052e27c107f": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "9e07495c-caf1-4f11-93c5-16cfeb63d492", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "b9bb984a-7a6d-4433-a445-1cde0383bece", + }, + }, + "693b16b8-a3b3-4419-ad03-61007a381c56": { + "217883ce-55f1-4c34-8e00-e86c743f4dff": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "2d864c10-146f-4905-859f-86e63c18abf4", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "42aef769-c97e-48a8-a3c4-2af8ad504ebb", + }, + }, + }; + + const options = ["4252d475-2d0e-4cd2-99d3-19fba34ef211", "456", "789"]; + + expect(getSkuIdFromOptions(options, variationMatrixSample)).toEqual( + undefined, + ); + }); + test("getSkuIDFromOptions should return undefined when proveded empty options.", () => { + const variationMatrixSample = {}; + + expect(getSkuIdFromOptions([], variationMatrixSample)).toEqual(undefined); + }); + + test("getOptionsFromSkuId should return a list of options for given sku id and matrix.", () => { + const variationMatrixSample = { + "option-1": { + "option-3": { + "option-5": "709e6cc6-a40c-4833-9469-b4abd0e7f67f", + "option-6": "c05839f5-3eac-48f2-9d36-1bc2a481a213", + }, + "option-4": { + "option-5": "9e07495c-caf1-4f11-93c5-16cfeb63d492", + "option-6": "b9bb984a-7a6d-4433-a445-1cde0383bece", + }, + }, + "option-2": { + "option-3": { + "option-5": "2d864c10-146f-4905-859f-86e63c18abf4", + "option-6": "42aef769-c97e-48a8-a3c4-2af8ad504ebb", + }, + }, + }; + + const expectedOutput = ["option-2", "option-3", "option-6"]; + + expect( + getOptionsFromSkuId( + "42aef769-c97e-48a8-a3c4-2af8ad504ebb", + variationMatrixSample, + ), + ).toEqual(expectedOutput); + }); + + test("mapOptionsToVariation should return the object mapping varitions to the selected option.", () => { + const variations: Partial[] = [ + { + id: "variation-1", + name: "Generic Sizes", + options: [ + { + id: "option-1", + description: "Small size", + name: "SM", + modifiers: [], + }, + { + id: "option-2", + description: "Medium size", + name: "MD", + modifiers: [], + }, + ], + }, + { + id: "variation-2", + name: "Simple T-Shirt Sleeve Length", + options: [ + { + id: "option-3", + description: "Simple T-Shirt with short sleeves", + name: "Short", + modifiers: [], + }, + { + id: "option-4", + description: "Simple T-Shirt with long sleeves", + name: "Long", + modifiers: [], + }, + ], + }, + ]; + + const selectedOptions = ["option-2", "option-3"]; + + const expectedOutput = { + "variation-1": "option-2", + "variation-2": "option-3", + }; + + expect( + mapOptionsToVariation(selectedOptions, variations as Variation[]), + ).toEqual(expectedOutput); + }); + + test("allVariationsHaveSelectedOption should return true if all variations keys have a defined value for their key value pair.", () => { + const variations: Partial[] = [ + { + id: "variation-1", + name: "Generic Sizes", + options: [ + { + id: "option-1", + description: "Small size", + name: "SM", + modifiers: [], + }, + { + id: "option-2", + description: "Medium size", + name: "MD", + modifiers: [], + }, + ], + }, + { + id: "variation-2", + name: "Simple T-Shirt Sleeve Length", + options: [ + { + id: "option-3", + description: "Simple T-Shirt with short sleeves", + name: "Short", + modifiers: [], + }, + { + id: "option-4", + description: "Simple T-Shirt with long sleeves", + name: "Long", + modifiers: [], + }, + ], + }, + ]; + + const optionDict = { + "variation-1": "option-2", + "variation-2": "option-3", + }; + + expect( + allVariationsHaveSelectedOption(optionDict, variations as Variation[]), + ).toEqual(true); + }); +}); diff --git a/examples/global-services/src/lib/product-helper.ts b/examples/global-services/src/lib/product-helper.ts new file mode 100644 index 00000000..446683f1 --- /dev/null +++ b/examples/global-services/src/lib/product-helper.ts @@ -0,0 +1,81 @@ +import { CatalogsProductVariation, ProductResponse } from "@elasticpath/js-sdk"; +import { OptionDict } from "./types/product-types"; +import { MatrixObjectEntry, MatrixValue } from "./types/matrix-object-entry"; + +export const getSkuIdFromOptions = ( + options: string[], + matrix: MatrixObjectEntry | MatrixValue, +): string | undefined => { + if (typeof matrix === "string") { + return matrix; + } + + for (const currOption in options) { + const nestedMatrix = matrix[options[currOption]]; + if (nestedMatrix) { + return getSkuIdFromOptions(options, nestedMatrix); + } + } + + return undefined; +}; + +export const getOptionsFromSkuId = ( + skuId: string, + entry: MatrixObjectEntry | MatrixValue, + options: string[] = [], +): string[] | undefined => { + if (typeof entry === "string") { + return entry === skuId ? options : undefined; + } + + let acc: string[] | undefined; + Object.keys(entry).every((key) => { + const result = getOptionsFromSkuId(skuId, entry[key], [...options, key]); + if (result) { + acc = result; + return false; + } + return true; + }); + return acc; +}; + +// TODO refactor +export const mapOptionsToVariation = ( + options: string[], + variations: CatalogsProductVariation[], +): OptionDict => { + return variations.reduce( + (acc: OptionDict, variation: CatalogsProductVariation) => { + const x = variation.options.find((varOption) => + options.some((selectedOption) => varOption.id === selectedOption), + )?.id; + return { ...acc, [variation.id]: x ? x : "" }; + }, + {}, + ); +}; + +export function allVariationsHaveSelectedOption( + optionsDict: OptionDict, + variations: CatalogsProductVariation[], +): boolean { + return !variations.some((variation) => !optionsDict[variation.id]); +} + +export const isChildProductResource = (product: ProductResponse): boolean => + !product.attributes.base_product && !!product.attributes.base_product_id; + +export const isSimpleProductResource = (product: ProductResponse): boolean => + !product.attributes.base_product && !product.attributes.base_product_id; + +/** + * promise will resolve after 300ms. + */ +export const wait300 = new Promise((resolve) => { + const wait = setTimeout(() => { + clearTimeout(wait); + resolve(); + }, 300); +}); diff --git a/examples/global-services/src/lib/product-util.test.ts b/examples/global-services/src/lib/product-util.test.ts new file mode 100644 index 00000000..d4c4e4e8 --- /dev/null +++ b/examples/global-services/src/lib/product-util.test.ts @@ -0,0 +1,315 @@ +import type { + File, + ProductResponse, + ShopperCatalogResource, + Variation, +} from "@elasticpath/js-sdk"; +import { describe, test, expect } from "vitest"; +import { + createEmptyOptionDict, + excludeChildProducts, + filterBaseProducts, + getProductMainImage, + processImageFiles, +} from "./product-util"; + +describe("product util", () => { + describe("unit tests", () => { + test("processImageFiles should return only supported images without the main image", () => { + const files: Partial[] = [ + { + type: "file", + id: "123", + mime_type: "image/jpeg", + }, + { + type: "file", + id: "456", + mime_type: "image/gif", + }, + { + type: "file", + id: "789", + mime_type: "image/jpeg", + }, + { + type: "file", + id: "101112", + mime_type: "image/png", + }, + { + type: "file", + id: "131415", + mime_type: "image/svg+xml", + }, + { + type: "file", + id: "161718", + mime_type: "image/webp", + }, + { + type: "file", + id: "192021", + mime_type: "video/mp4", + }, + { + type: "file", + id: "222324", + mime_type: "application/pdf", + }, + { + type: "file", + id: "252627", + mime_type: "application/vnd.ms-excel", + }, + { + type: "file", + id: "282930", + mime_type: "application/vnd.ms-powerpoint", + }, + { + type: "file", + id: "313233", + mime_type: "application/msword", + }, + ]; + + const expected: Partial[] = [ + { + type: "file", + id: "456", + mime_type: "image/gif", + }, + { + type: "file", + id: "789", + mime_type: "image/jpeg", + }, + { + type: "file", + id: "101112", + mime_type: "image/png", + }, + { + type: "file", + id: "131415", + mime_type: "image/svg+xml", + }, + { + type: "file", + id: "161718", + mime_type: "image/webp", + }, + ]; + expect(processImageFiles(files as File[], "123")).toEqual(expected); + }); + + test("processImageFiles should support an undefined main image id", () => { + const files: Partial[] = [ + { + type: "file", + id: "123", + mime_type: "image/jpeg", + }, + { + type: "file", + id: "456", + mime_type: "image/gif", + }, + ]; + + const expected: Partial[] = [ + { + type: "file", + id: "123", + mime_type: "image/jpeg", + }, + { + type: "file", + id: "456", + mime_type: "image/gif", + }, + ]; + expect(processImageFiles(files as File[])).toEqual(expected); + }); + + test("getProductMainImage should return a products main image file", () => { + const mainImageFile: Partial = { + type: "file", + id: "123", + mime_type: "image/jpeg", + }; + + const productResp: Partial> = { + included: { + main_images: [mainImageFile] as File[], + }, + }; + + expect(getProductMainImage(productResp.included?.main_images)).toEqual( + mainImageFile, + ); + }); + + test("getProductMainImage should return null when product does not have main image included", () => { + const productResp: Partial> = { + included: {}, + }; + + expect(getProductMainImage(productResp.included?.main_images)).toEqual( + null, + ); + }); + + test("createEmptyOptionDict should return an OptionDict with all with variation keys assigned undefined values", () => { + const variations: Partial[] = [ + { + id: "variation-1", + name: "Generic Sizes", + options: [ + { + id: "option-1", + description: "Small size", + name: "SM", + modifiers: [], + }, + { + id: "option-2", + description: "Medium size", + name: "MD", + modifiers: [], + }, + ], + }, + { + id: "variation-2", + name: "Simple T-Shirt Sleeve Length", + options: [ + { + id: "option-3", + description: "Simple T-Shirt with short sleeves", + name: "Short", + modifiers: [], + }, + { + id: "option-4", + description: "Simple T-Shirt with long sleeves", + name: "Long", + modifiers: [], + }, + ], + }, + ]; + + const optionDict = { + "variation-1": undefined, + "variation-2": undefined, + }; + + expect(createEmptyOptionDict(variations as Variation[])).toEqual( + optionDict, + ); + }); + + test("filterBaseProducts should return only the base products from a list of ProductResponse", () => { + const products: any = [ + { + id: "123", + attributes: { + base_product: false, + base_product_id: "789", + }, + relationships: { + parent: { + data: { + id: "parent-id", + type: "product", + }, + }, + }, + }, + { + id: "456", + attributes: { + base_product: false, + }, + relationships: {}, + }, + { + id: "789", + attributes: { + base_product: true, + }, + relationships: {}, + }, + ]; + + const expected = [ + { + id: "789", + attributes: { + base_product: true, + }, + relationships: {}, + }, + ]; + const actual = filterBaseProducts(products as ProductResponse[]); + expect(actual).toEqual(expected); + }); + + test("excludeChildProducts should return only the products that are not child products", () => { + const products: any = [ + { + id: "123", + attributes: { + base_product: false, + base_product_id: "789", + }, + relationships: { + parent: { + data: { + id: "parent-id", + type: "product", + }, + }, + }, + }, + { + id: "456", + attributes: { + base_product: false, + }, + relationships: {}, + }, + { + id: "789", + attributes: { + base_product: true, + }, + relationships: {}, + }, + ]; + + const expected = [ + { + id: "456", + attributes: { + base_product: false, + }, + relationships: {}, + }, + { + id: "789", + attributes: { + base_product: true, + }, + relationships: {}, + }, + ]; + + expect(excludeChildProducts(products as ProductResponse[])).toEqual( + expected, + ); + }); + }); +}); diff --git a/examples/global-services/src/lib/product-util.ts b/examples/global-services/src/lib/product-util.ts new file mode 100644 index 00000000..7fea4739 --- /dev/null +++ b/examples/global-services/src/lib/product-util.ts @@ -0,0 +1,54 @@ +import type { + CatalogsProductVariation, + File, + ProductResponse, +} from "@elasticpath/js-sdk"; +import type { + IdentifiableBaseProduct, + OptionDict, +} from "./types/product-types"; + +export function processImageFiles(files: File[], mainImageId?: string) { + // filters out main image and keeps server order + const supportedMimeTypes = [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + "image/svg+xml", + ]; + return files.filter( + (fileEntry) => + fileEntry.id !== mainImageId && + supportedMimeTypes.some((type) => fileEntry.mime_type === type), + ); +} + +export function getProductMainImage( + mainImages: File[] | undefined, +): File | null { + return mainImages?.[0] || null; +} + +// Using existance of parent relationship property to filter because only child products seem to have this property. +export const filterBaseProducts = ( + products: ProductResponse[], +): IdentifiableBaseProduct[] => + products.filter( + (product: ProductResponse): product is IdentifiableBaseProduct => + product.attributes.base_product, + ); + +// Using existance of parent relationship property to filter because only child products seem to have this property. +export const excludeChildProducts = ( + products: ProductResponse[], +): IdentifiableBaseProduct[] => + products.filter( + (product: ProductResponse): product is IdentifiableBaseProduct => + !product?.relationships?.parent, + ); + +export const createEmptyOptionDict = ( + variations: CatalogsProductVariation[], +): OptionDict => + variations.reduce((acc, c) => ({ ...acc, [c.id]: undefined }), {}); diff --git a/examples/global-services/src/lib/resolve-cart-env.ts b/examples/global-services/src/lib/resolve-cart-env.ts new file mode 100644 index 00000000..d4f29eba --- /dev/null +++ b/examples/global-services/src/lib/resolve-cart-env.ts @@ -0,0 +1,11 @@ +export const COOKIE_PREFIX_KEY = cartEnv(); + +function cartEnv(): string { + const cookiePrefixKey = process.env.NEXT_PUBLIC_COOKIE_PREFIX_KEY; + if (!cookiePrefixKey) { + throw new Error( + `Failed to get cart cookie key environment variables cookiePrefixKey. \n Make sure you have set NEXT_PUBLIC_COOKIE_PREFIX_KEY`, + ); + } + return cookiePrefixKey; +} diff --git a/examples/global-services/src/lib/resolve-ep-currency-code.ts b/examples/global-services/src/lib/resolve-ep-currency-code.ts new file mode 100644 index 00000000..999cdaf3 --- /dev/null +++ b/examples/global-services/src/lib/resolve-ep-currency-code.ts @@ -0,0 +1,13 @@ +import { getCookie } from "cookies-next"; +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; + +export const EP_CURRENCY_CODE = retrieveCurrency(); + +function retrieveCurrency(): string { + const currencyInCookie = getCookie(`${COOKIE_PREFIX_KEY}_ep_currency`); + return ( + (typeof currencyInCookie === "string" + ? currencyInCookie + : process.env.NEXT_PUBLIC_DEFAULT_CURRENCY_CODE) || "USD" + ); +} diff --git a/examples/global-services/src/lib/resolve-epcc-env.ts b/examples/global-services/src/lib/resolve-epcc-env.ts new file mode 100644 index 00000000..7852a5c9 --- /dev/null +++ b/examples/global-services/src/lib/resolve-epcc-env.ts @@ -0,0 +1,21 @@ +export const epccEnv = resolveEpccEnv(); + +function resolveEpccEnv(): { + client_id: string; + host?: string; + client_secret?: string; +} { + const { host, client_id, client_secret } = { + host: process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL, + client_id: process.env.NEXT_PUBLIC_EPCC_CLIENT_ID, + client_secret: process.env.EPCC_CLIENT_SECRET, + }; + + if (!client_id) { + throw new Error( + `Failed to get Elasticpath Commerce Cloud client_id environment variables client_id: \n Make sure you have set NEXT_PUBLIC_EPCC_CLIENT_ID`, + ); + } + + return { host, client_id, client_secret }; +} diff --git a/examples/global-services/src/lib/retrieve-account-member-credentials.ts b/examples/global-services/src/lib/retrieve-account-member-credentials.ts new file mode 100644 index 00000000..2666ad33 --- /dev/null +++ b/examples/global-services/src/lib/retrieve-account-member-credentials.ts @@ -0,0 +1,49 @@ +import { cookies } from "next/headers"; +import { + AccountMemberCredential, + AccountMemberCredentials, + accountMemberCredentialsSchema, +} from "../app/(auth)/account-member-credentials-schema"; + +export function getSelectedAccount( + memberCredentials: AccountMemberCredentials, +): AccountMemberCredential { + const selectedAccount = + memberCredentials.accounts[memberCredentials.selected]; + if (!selectedAccount) { + throw new Error("No selected account"); + } + return selectedAccount; +} + +export function retrieveAccountMemberCredentials( + cookieStore: ReturnType, + name: string, +) { + const accountMemberCookie = cookieStore.get(name); + + // Next.js cookieStore.delete replaces a cookie with an empty string so we need to check for that here. + if (!accountMemberCookie || !accountMemberCookie.value) { + return undefined; + } + + return parseAccountMemberCredentialsCookieStr(accountMemberCookie?.value); +} + +export function parseAccountMemberCredentialsCookieStr( + str: string, +): AccountMemberCredentials | undefined { + const parsedCookie = accountMemberCredentialsSchema.safeParse( + JSON.parse(str), + ); + + if (!parsedCookie.success) { + console.error( + "Failed to parse account member cookie: ", + parsedCookie.error, + ); + return undefined; + } + + return parsedCookie.data; +} diff --git a/examples/global-services/src/lib/sort-alphabetically.ts b/examples/global-services/src/lib/sort-alphabetically.ts new file mode 100644 index 00000000..a69ce476 --- /dev/null +++ b/examples/global-services/src/lib/sort-alphabetically.ts @@ -0,0 +1,4 @@ +export const sortAlphabetically = ( + a: { name: string }, + b: { name: string }, +): number => a.name.localeCompare(b.name); diff --git a/examples/global-services/src/lib/sort-by-items.ts b/examples/global-services/src/lib/sort-by-items.ts new file mode 100644 index 00000000..523791a5 --- /dev/null +++ b/examples/global-services/src/lib/sort-by-items.ts @@ -0,0 +1,13 @@ +import { KlevuSearchSorting } from "@klevu/core"; + +export const sortByItems = [ + { label: "Featured", value: undefined }, + { + label: "Price (Low to High)", + value: KlevuSearchSorting.PriceAsc, + }, + { + label: "Price (High to Low)", + value: KlevuSearchSorting.PriceDesc, + }, +]; diff --git a/examples/global-services/src/lib/to-base-64.ts b/examples/global-services/src/lib/to-base-64.ts new file mode 100644 index 00000000..87b9edc2 --- /dev/null +++ b/examples/global-services/src/lib/to-base-64.ts @@ -0,0 +1,4 @@ +export const toBase64 = (str: string): string => + typeof window === "undefined" + ? Buffer.from(str).toString("base64") + : window.btoa(str); diff --git a/examples/global-services/src/lib/token-expired.ts b/examples/global-services/src/lib/token-expired.ts new file mode 100644 index 00000000..694a760f --- /dev/null +++ b/examples/global-services/src/lib/token-expired.ts @@ -0,0 +1,3 @@ +export function tokenExpired(expires: number): boolean { + return Math.floor(Date.now() / 1000) >= expires; +} diff --git a/examples/global-services/src/lib/types/breadcrumb-lookup.ts b/examples/global-services/src/lib/types/breadcrumb-lookup.ts new file mode 100644 index 00000000..f59ca3c9 --- /dev/null +++ b/examples/global-services/src/lib/types/breadcrumb-lookup.ts @@ -0,0 +1,7 @@ +export interface BreadcrumbLookupEntry { + href: string; + name: string; + slug: string; +} + +export type BreadcrumbLookup = Record; diff --git a/examples/global-services/src/lib/types/deep-partial.ts b/examples/global-services/src/lib/types/deep-partial.ts new file mode 100644 index 00000000..e422dd51 --- /dev/null +++ b/examples/global-services/src/lib/types/deep-partial.ts @@ -0,0 +1,3 @@ +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; diff --git a/examples/global-services/src/lib/types/matrix-object-entry.ts b/examples/global-services/src/lib/types/matrix-object-entry.ts new file mode 100644 index 00000000..a214bbc1 --- /dev/null +++ b/examples/global-services/src/lib/types/matrix-object-entry.ts @@ -0,0 +1,5 @@ +export type MatrixValue = string; + +export interface MatrixObjectEntry { + [key: string]: MatrixObjectEntry | MatrixValue; +} diff --git a/examples/global-services/src/lib/types/non-empty-array.ts b/examples/global-services/src/lib/types/non-empty-array.ts new file mode 100644 index 00000000..af797df1 --- /dev/null +++ b/examples/global-services/src/lib/types/non-empty-array.ts @@ -0,0 +1,3 @@ +export interface NonEmptyArray extends Array { + 0: A; +} diff --git a/examples/global-services/src/lib/types/product-types.ts b/examples/global-services/src/lib/types/product-types.ts new file mode 100644 index 00000000..e2cc5337 --- /dev/null +++ b/examples/global-services/src/lib/types/product-types.ts @@ -0,0 +1,31 @@ +import type { ProductResponse, File } from "@elasticpath/js-sdk"; +import type { Dispatch, SetStateAction } from "react"; + +export type IdentifiableBaseProduct = ProductResponse & { + id: string; + attributes: { slug: string; sku: string; base_product: true }; +}; + +export interface ProductContextState { + isChangingSku: boolean; + setIsChangingSku: Dispatch>; +} + +export interface ProductModalContextState { + isChangingSku: boolean; + setIsChangingSku: Dispatch>; + changedSkuId: string; + setChangedSkuId: Dispatch>; +} + +export interface OptionDict { + [key: string]: string; +} + +export interface ProductResponseWithImage extends ProductResponse { + main_image?: File; +} + +export interface ProductImageObject { + [key: string]: File; +} diff --git a/examples/global-services/src/lib/types/read-only-non-empty-array.ts b/examples/global-services/src/lib/types/read-only-non-empty-array.ts new file mode 100644 index 00000000..9877640a --- /dev/null +++ b/examples/global-services/src/lib/types/read-only-non-empty-array.ts @@ -0,0 +1,7 @@ +export type ReadonlyNonEmptyArray = ReadonlyArray & { + readonly 0: A; +}; + +export const isNonEmpty = ( + as: ReadonlyArray, +): as is ReadonlyNonEmptyArray => as.length > 0; diff --git a/examples/global-services/src/lib/types/unpacked.ts b/examples/global-services/src/lib/types/unpacked.ts new file mode 100644 index 00000000..733a5208 --- /dev/null +++ b/examples/global-services/src/lib/types/unpacked.ts @@ -0,0 +1,7 @@ +/** + * https://stackoverflow.com/a/52331580/4330441 + * Extract the type of array e.g. + * type Group = Item[] + * type MyItem = Unpacked + */ +export type Unpacked = T extends (infer U)[] ? U : T; diff --git a/examples/global-services/src/lib/use-debounced.ts b/examples/global-services/src/lib/use-debounced.ts new file mode 100644 index 00000000..9f963143 --- /dev/null +++ b/examples/global-services/src/lib/use-debounced.ts @@ -0,0 +1,14 @@ +import { DependencyList, EffectCallback, useEffect } from "react"; + +export const useDebouncedEffect = ( + effect: EffectCallback, + delay: number, + deps?: DependencyList, +) => { + useEffect(() => { + const handler = setTimeout(() => effect(), delay); + + return () => clearTimeout(handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...(deps || []), delay]); +}; diff --git a/examples/global-services/src/middleware.ts b/examples/global-services/src/middleware.ts new file mode 100644 index 00000000..a06a5712 --- /dev/null +++ b/examples/global-services/src/middleware.ts @@ -0,0 +1,21 @@ +import { NextRequest } from "next/server"; +import { middlewareRunner } from "./lib/middleware/middleware-runner"; +import { implicitAuthMiddleware } from "./lib/middleware/implicit-auth-middleware"; +import { cartCookieMiddleware } from "./lib/middleware/cart-cookie-middleware"; + +export async function middleware(req: NextRequest) { + return middlewareRunner( + { + runnable: implicitAuthMiddleware, + options: { + exclude: ["/_next", "/configuration-error"], + }, + }, + { + runnable: cartCookieMiddleware, + options: { + exclude: ["/_next", "/configuration-error"], + }, + }, + )(req); +} diff --git a/examples/global-services/src/services/cart.ts b/examples/global-services/src/services/cart.ts new file mode 100644 index 00000000..e92757e4 --- /dev/null +++ b/examples/global-services/src/services/cart.ts @@ -0,0 +1,9 @@ +import type {ElasticPath } from "@elasticpath/js-sdk"; +import { Cart, CartIncluded, ResourceIncluded } from "@elasticpath/js-sdk"; + +export async function getCart( + cartId: string, + client: ElasticPath, +): Promise> { + return client.Cart(cartId).With("items").Get(); +} diff --git a/examples/global-services/src/services/hierarchy.ts b/examples/global-services/src/services/hierarchy.ts new file mode 100644 index 00000000..6fafee61 --- /dev/null +++ b/examples/global-services/src/services/hierarchy.ts @@ -0,0 +1,28 @@ +import type { Node, Hierarchy } from "@elasticpath/js-sdk"; +import {ElasticPath } from "@elasticpath/js-sdk"; + +export async function getHierarchies(client: ElasticPath): Promise { + const result = await client.ShopperCatalog.Hierarchies.All(); + return result.data; +} + +export async function getHierarchyChildren( + hierarchyId: string, + client: ElasticPath, +): Promise { + const result = await client.ShopperCatalog.Hierarchies.GetHierarchyChildren({ + hierarchyId, + }); + return result.data; +} + +export async function getHierarchyNodes( + hierarchyId: string, + client: ElasticPath, +): Promise { + const result = await client.ShopperCatalog.Hierarchies.GetHierarchyNodes({ + hierarchyId, + }); + + return result.data; +} diff --git a/examples/global-services/src/services/products.ts b/examples/global-services/src/services/products.ts new file mode 100644 index 00000000..c8f49f64 --- /dev/null +++ b/examples/global-services/src/services/products.ts @@ -0,0 +1,68 @@ +import type { + ProductResponse, + ResourcePage, + ShopperCatalogResource, +} from "@elasticpath/js-sdk"; +import { wait300 } from "../lib/product-helper"; +import { ElasticPath } from "@elasticpath/js-sdk"; + +export async function getProductById( + productId: string, + client: ElasticPath, +): Promise> { + return client.ShopperCatalog.Products.With([ + "main_image", + "files", + "component_products", + ]).Get({ + productId, + }); +} + +export function getAllProducts(client: ElasticPath): Promise { + return _getAllProductPages(client)(); +} + +export function getProducts(client: ElasticPath, offset = 0, limit = 100) { + return client.ShopperCatalog.Products.With(["main_image"]) + .Limit(limit) + .Offset(offset) + .All(); +} + +const _getAllPages = + ( + nextPageRequestFn: ( + limit: number, + offset: number, + client?: ElasticPath, + ) => Promise>, + ) => + async ( + offset: number = 0, + limit: number = 25, + accdata: T[] = [], + ): Promise => { + const requestResp = await nextPageRequestFn(limit, offset); + const { + meta: { + page: newPage, + results: { total }, + }, + data: newData, + } = requestResp; + + const updatedOffset = offset + newPage.total; + const combinedData = [...accdata, ...newData]; + if (updatedOffset < total) { + return wait300.then(() => + _getAllPages(nextPageRequestFn)(updatedOffset, limit, combinedData), + ); + } + return Promise.resolve(combinedData); + }; + +const _getAllProductPages = (client: ElasticPath) => + _getAllPages((limit = 25, offset = 0) => + client.ShopperCatalog.Products.Limit(limit).Offset(offset).All(), + ); diff --git a/examples/global-services/src/styles/globals.css b/examples/global-services/src/styles/globals.css new file mode 100644 index 00000000..766ef9db --- /dev/null +++ b/examples/global-services/src/styles/globals.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + padding: 0; + margin: 0; + height: 100%; + font-family: + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Roboto, + Oxygen, + Ubuntu, + Cantarell, + Fira Sans, + Droid Sans, + Helvetica Neue, + sans-serif; +} + +.carousel__slide-focus-ring { + display: none !important; + outline-width: 0 !important; +} diff --git a/examples/global-services/tailwind.config.ts b/examples/global-services/tailwind.config.ts new file mode 100644 index 00000000..fc51cc8d --- /dev/null +++ b/examples/global-services/tailwind.config.ts @@ -0,0 +1,78 @@ +import plugin from "tailwindcss/plugin"; +import type { Config } from "tailwindcss"; + +export default { + content: ["./src/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: { + maxWidth: { + "base-max-width": "80rem", + }, + flex: { + "only-grow": "1 0 0%", + }, + colors: { + brand: { + primary: "#2BCC7E", + secondary: "#144E31", + highlight: "#56DC9B", + primaryAlt: "#EA7317", + secondaryAlt: "#ffcb47", + gray: "#666666", + }, + }, + keyframes: { + fadeIn: { + from: { opacity: "0" }, + to: { opacity: "1" }, + }, + marquee: { + "0%": { transform: "translateX(0%)" }, + "100%": { transform: "translateX(-100%)" }, + }, + blink: { + "0%": { opacity: "0.2" }, + "20%": { opacity: "1" }, + "100% ": { opacity: "0.2" }, + }, + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + fadeIn: "fadeIn .3s ease-in-out", + carousel: "marquee 60s linear infinite", + blink: "blink 1.4s both infinite", + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + clipPath: { + sidebar: "polygon(0 0, 100vmax 0, 100vmax 100%, 0 100%)", + }, + }, + }, + plugins: [ + require("@tailwindcss/forms"), + require("tailwindcss-animate"), + require("tailwind-clip-path"), + plugin(({ matchUtilities, theme }) => { + matchUtilities( + { + "animation-delay": (value) => { + return { + "animation-delay": value, + }; + }, + }, + { + values: theme("transitionDelay"), + }, + ); + }), + ], +} satisfies Config; diff --git a/examples/global-services/tsconfig.json b/examples/global-services/tsconfig.json new file mode 100644 index 00000000..36c7f895 --- /dev/null +++ b/examples/global-services/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "es6", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "src/**/*.ts", + "src/**/*.tsx", + ".next/types/**/*.ts", + "tailwind.config.ts" + ], + "exclude": ["node_modules"], + "types": ["global.d.ts"] +} diff --git a/examples/global-services/vite.config.ts b/examples/global-services/vite.config.ts new file mode 100644 index 00000000..9da935db --- /dev/null +++ b/examples/global-services/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig, defaultExclude } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["e2e/**/*", ...defaultExclude], + coverage: { + provider: "istanbul", + }, + }, +}); diff --git a/examples/klevu/.composablerc b/examples/klevu/.composablerc new file mode 100644 index 00000000..7537a1b3 --- /dev/null +++ b/examples/klevu/.composablerc @@ -0,0 +1,6 @@ +{ + "version": 1, + "cli": { + "packageManager": "pnpm" + } +} \ No newline at end of file diff --git a/examples/klevu/.eslintrc.json b/examples/klevu/.eslintrc.json new file mode 100644 index 00000000..d7dcbb98 --- /dev/null +++ b/examples/klevu/.eslintrc.json @@ -0,0 +1,12 @@ +{ + "extends": ["next/core-web-vitals", "prettier"], + "plugins": ["react"], + "parserOptions": { + "ecmaFeatures": { + "jsx": true + } + }, + "rules": { + "react/jsx-curly-brace-presence": "error" + } +} diff --git a/examples/klevu/.gitignore b/examples/klevu/.gitignore new file mode 100644 index 00000000..537bd7aa --- /dev/null +++ b/examples/klevu/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.idea/ + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.* + +# vercel +.vercel + +# typescript +*.tsbuildinfo + +# Being generated by the moltin js-sdk during dev server from server side requests +/localStorage + +test-results \ No newline at end of file diff --git a/examples/klevu/.lintstagedrc.js b/examples/klevu/.lintstagedrc.js new file mode 100644 index 00000000..0fadc0d4 --- /dev/null +++ b/examples/klevu/.lintstagedrc.js @@ -0,0 +1,32 @@ +const path = require("path"); + +/** + * Using next lint with lint-staged requires this setup + * https://nextjs.org/docs/basic-features/eslint#lint-staged + */ + +const buildEslintCommand = (filenames) => + `next lint --fix --file ${filenames + .map((f) => path.relative(process.cwd(), f)) + .join(" --file ")}`; + +/** + * () => "npm run type:check" + * needs to be a function because arguments are getting passed from lint-staged + * when those arguments get through to the "tsc" command that "npm run type:check" + * is calling the args cause "tsc" to ignore the tsconfig.json in our root directory. + * https://github.com/microsoft/TypeScript/issues/27379 + */ +module.exports = { + "*.{js,jsx}": [ + "npm run format:fix", + buildEslintCommand, + "npm run format:check", + ], + "*.{ts,tsx}": [ + "npm run format:fix", + () => "npm run type:check", + buildEslintCommand, + "npm run format:check", + ], +}; diff --git a/examples/klevu/.prettierignore b/examples/klevu/.prettierignore new file mode 100644 index 00000000..b14c3ee4 --- /dev/null +++ b/examples/klevu/.prettierignore @@ -0,0 +1 @@ +**/.next/** \ No newline at end of file diff --git a/examples/klevu/.prettierrc b/examples/klevu/.prettierrc new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/examples/klevu/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/examples/klevu/README.md b/examples/klevu/README.md new file mode 100644 index 00000000..71542d0e --- /dev/null +++ b/examples/klevu/README.md @@ -0,0 +1,59 @@ +# klevu Elastic Path storefront starter + +This project was generated with [Composable CLI](https://www.npmjs.com/package/composable-cli). + +This storefront accelerates the development of a direct-to-consumer ecommerce experience using Elastic Path's modular products. + +## Tech Stack + +- [Elastic Path](https://www.elasticpath.com/products): A family of composable products for businesses that need to quickly & easily create unique experiences and next-level customer engagements that drive revenue. + +- [Next.js](https://nextjs.org/): a React framework for building static and server-side rendered applications + +- [Tailwind CSS](https://tailwindcss.com/): enabling you to get started with a range of out the box components that are + easy to customize + +- [Headless UI](https://headlessui.com/): completely unstyled, fully accessible UI components, designed to integrate + beautifully with Tailwind CSS. + +- [Radix UI Primitives](https://www.radix-ui.com/primitives): Unstyled, accessible, open source React primitives for high-quality web apps and design systems. + +- [Typescript](https://www.typescriptlang.org/): a typed superset of JavaScript that compiles to plain JavaScript + +## Getting Started + +Run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page will hot reload as you edit the file. + +## Deployment + +Deployment is typical for a Next.js site. We recommend using a provider +like [Netlify](https://www.netlify.com/blog/2020/11/30/how-to-deploy-next.js-sites-to-netlify/) +or [Vercel](https://vercel.com/docs/frameworks/nextjs) to get full Next.js feature support. + +## Current feature set reference + +| **Feature** | **Notes** | +|------------------------------------------|-----------------------------------------------------------------------------------------------| +| PDP | Product Display Pages | +| PLP | Product Listing Pages. | +| EPCC PXM product variations | [Learn more](https://elasticpath.dev/docs/pxm/products/pxm-product-variations/pxm-variations) | +| EPCC PXM bundles | [Learn more](https://elasticpath.dev/docs/pxm/products/pxm-bundles/pxm-bundles) | +| EPCC PXM hierarchy-based navigation menu | Main site nav driven directly from your store's hierarchy and node structure | +| Prebuilt helper components | Some basic building blocks for typical ecommerce store features | +| Checkout | [Learn more](https://elasticpath.dev/docs/commerce-cloud/checkout/checkout-workflow) | +| Cart | [Learn more](https://elasticpath.dev/docs/commerce-cloud/carts/carts) | + diff --git a/examples/klevu/e2e/checkout-flow.spec.ts b/examples/klevu/e2e/checkout-flow.spec.ts new file mode 100644 index 00000000..7e865658 --- /dev/null +++ b/examples/klevu/e2e/checkout-flow.spec.ts @@ -0,0 +1,41 @@ +import { test } from "@playwright/test"; +import { createD2CProductDetailPage } from "./models/d2c-product-detail-page"; +import { client } from "./util/epcc-client"; +import { createD2CCartPage } from "./models/d2c-cart-page"; +import { createD2CCheckoutPage } from "./models/d2c-checkout-page"; + +test.describe("Checkout flow", async () => { + test("should perform product checkout", async ({ page }) => { + const productDetailPage = createD2CProductDetailPage(page, client); + const cartPage = createD2CCartPage(page); + const checkoutPage = createD2CCheckoutPage(page); + + /* Go to simple product page */ + await productDetailPage.gotoSimpleProduct(); + + /* Add the product to cart */ + await productDetailPage.addProductToCart(); + + /* Go to cart page and checkout */ + await cartPage.goto(); + await cartPage.checkoutCart(); + + /* Enter information */ + await checkoutPage.enterInformation({ + "Email Address": { value: "test@tester.com", fieldType: "input" }, + "First Name": { value: "Jim", fieldType: "input" }, + "Last Name": { value: "Brown", fieldType: "input" }, + Address: { value: "Main Street", fieldType: "input" }, + City: { value: "Brownsville", fieldType: "input" }, + Region: { value: "Browns", fieldType: "input" }, + Postcode: { value: "ABC 123", fieldType: "input" }, + Country: { value: "Algeria", fieldType: "combobox" }, + "Phone Number": { value: "01234567891", fieldType: "input" }, + }); + + await checkoutPage.checkout(); + + /* Continue Shopping */ + await checkoutPage.continueShopping(); + }); +}); diff --git a/examples/klevu/e2e/home-page.spec.ts b/examples/klevu/e2e/home-page.spec.ts new file mode 100644 index 00000000..de251ea1 --- /dev/null +++ b/examples/klevu/e2e/home-page.spec.ts @@ -0,0 +1,10 @@ +import { test } from "@playwright/test"; +import { createD2CHomePage } from "./models/d2c-home-page"; +import { skipIfMissingCatalog } from "./util/missing-published-catalog"; + +test.describe("Home Page", async () => { + test("should load home page", async ({ page }) => { + const d2cHomePage = createD2CHomePage(page); + await d2cHomePage.goto(); + }); +}); diff --git a/examples/klevu/e2e/models/d2c-cart-page.ts b/examples/klevu/e2e/models/d2c-cart-page.ts new file mode 100644 index 00000000..34626aca --- /dev/null +++ b/examples/klevu/e2e/models/d2c-cart-page.ts @@ -0,0 +1,23 @@ +import type { Locator, Page } from "@playwright/test"; + +export interface D2CCartPage { + readonly page: Page; + readonly checkoutBtn: Locator; + readonly goto: () => Promise; + readonly checkoutCart: () => Promise; +} + +export function createD2CCartPage(page: Page): D2CCartPage { + const checkoutBtn = page.getByRole("link", { name: "Checkout" }); + + return { + page, + checkoutBtn, + async goto() { + await page.goto(`/cart`); + }, + async checkoutCart() { + await checkoutBtn.click(); + }, + }; +} diff --git a/examples/klevu/e2e/models/d2c-checkout-page.ts b/examples/klevu/e2e/models/d2c-checkout-page.ts new file mode 100644 index 00000000..e1efec42 --- /dev/null +++ b/examples/klevu/e2e/models/d2c-checkout-page.ts @@ -0,0 +1,48 @@ +import type { Locator, Page } from "@playwright/test"; +import { fillAllFormFields, FormInput } from "../util/fill-form-field"; +import { enterPaymentInformation as _enterPaymentInformation } from "../util/enter-payment-information"; + +export interface D2CCheckoutPage { + readonly page: Page; + readonly payNowBtn: Locator; + readonly checkoutBtn: Locator; + readonly goto: () => Promise; + readonly enterInformation: (values: FormInput) => Promise; + readonly checkout: () => Promise; + readonly enterPaymentInformation: (values: FormInput) => Promise; + readonly submitPayment: () => Promise; + readonly continueShopping: () => Promise; +} + +export function createD2CCheckoutPage(page: Page): D2CCheckoutPage { + const payNowBtn = page.getByRole("button", { name: "Pay $" }); + const checkoutBtn = page.getByRole("button", { name: "Pay $" }); + const continueShoppingBtn = page.getByRole("link", { + name: "Continue shopping", + }); + + return { + page, + payNowBtn, + checkoutBtn, + async goto() { + await page.goto(`/cart`); + }, + async enterPaymentInformation(values: FormInput) { + await _enterPaymentInformation(page, values); + }, + async enterInformation(values: FormInput) { + await fillAllFormFields(page, values); + }, + async submitPayment() { + await payNowBtn.click(); + }, + async checkout() { + await checkoutBtn.click(); + }, + async continueShopping() { + await continueShoppingBtn.click(); + await page.waitForURL("/"); + }, + }; +} diff --git a/examples/klevu/e2e/models/d2c-home-page.ts b/examples/klevu/e2e/models/d2c-home-page.ts new file mode 100644 index 00000000..9a847b8b --- /dev/null +++ b/examples/klevu/e2e/models/d2c-home-page.ts @@ -0,0 +1,15 @@ +import type { Page } from "@playwright/test"; + +export interface D2CHomePage { + readonly page: Page; + readonly goto: () => Promise; +} + +export function createD2CHomePage(page: Page): D2CHomePage { + return { + page, + async goto() { + await page.goto("/"); + }, + }; +} diff --git a/examples/klevu/e2e/models/d2c-product-detail-page.ts b/examples/klevu/e2e/models/d2c-product-detail-page.ts new file mode 100644 index 00000000..538ed27e --- /dev/null +++ b/examples/klevu/e2e/models/d2c-product-detail-page.ts @@ -0,0 +1,132 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { + getProductById, + getSimpleProduct, + getVariationsProduct, +} from "../util/resolver-product-from-store"; +import type {ElasticPath, ProductResponse } from "@elasticpath/js-sdk"; +import { getCartId } from "../util/get-cart-id"; +import { getSkuIdFromOptions } from "../../src/lib/product-helper"; + +const host = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL; + +export interface D2CProductDetailPage { + readonly page: Page; + readonly gotoSimpleProduct: () => Promise; + readonly gotoVariationsProduct: () => Promise; + readonly getCartId: () => Promise; + readonly addProductToCart: () => Promise; + readonly gotoProductVariation: () => Promise; +} + +export function createD2CProductDetailPage( + page: Page, + client: ElasticPath, +): D2CProductDetailPage { + let activeProduct: ProductResponse | undefined; + const addToCartBtn = page.getByRole("button", { name: "Add to Cart" }); + + return { + page, + async gotoSimpleProduct() { + activeProduct = await getSimpleProduct(client); + await skipOrGotoProduct( + page, + "Can't run test because there is no simple product published in the store.", + activeProduct, + ); + }, + async gotoVariationsProduct() { + activeProduct = await getVariationsProduct(client); + await skipOrGotoProduct( + page, + "Can't run test because there is no variation product published in the store.", + activeProduct, + ); + }, + async gotoProductVariation() { + expect( + activeProduct, + "Make sure you call one of the gotoVariationsProduct function first before calling gotoProductVariation", + ).toBeDefined(); + expect(activeProduct?.attributes.base_product).toEqual(true); + + const expectedProductId = await selectOptions(activeProduct!, page); + const product = await getProductById(client, expectedProductId); + + expect(product.data?.id).toBeDefined(); + activeProduct = product.data; + + /* Check to make sure the page has navigated to the selected product */ + await expect(page).toHaveURL(`/products/${expectedProductId}`); + }, + getCartId: getCartId(page), + async addProductToCart() { + expect( + activeProduct, + "Make sure you call one of the gotoProduct function first before calling addProductToCart", + ).toBeDefined(); + /* Get the cart id */ + const cartId = await getCartId(page)(); + + /* Add the product to cart */ + await addToCartBtn.click(); + /* Wait for the cart POST request to complete */ + const reqUrl = `https://${host}/v2/carts/${cartId}/items`; + await page.waitForResponse(reqUrl); + + /* Check to make sure the product has been added to cart */ + const result = await client.Cart(cartId).With("items").Get(); + await expect( + activeProduct?.attributes.price, + "Missing price on active product - make sure the product has a price set can't add to cart without one.", + ).toBeDefined(); + await expect( + result.included?.items.find( + (item) => item.product_id === activeProduct!.id, + ), + ).toHaveProperty("product_id", activeProduct!.id); + }, + }; +} + +async function skipOrGotoProduct( + page: Page, + msg: string, + product?: ProductResponse, +) { + if (!product) { + test.skip(!product, msg); + } else { + await page.goto(`/products/${product.id}`); + } +} + +async function selectOptions( + baseProduct: ProductResponse, + page: Page, +): Promise { + /* select one of each variation option */ + const options = baseProduct.meta.variations?.reduce((acc, variation) => { + return [...acc, ...([variation.options?.[0]] ?? [])]; + }, []); + + if (options && baseProduct.meta.variation_matrix) { + for (const option of options) { + await page.click(`text=${option.name}`); + } + + const variationId = getSkuIdFromOptions( + options.map((x) => x.id), + baseProduct.meta.variation_matrix, + ); + + if (!variationId) { + throw new Error("Unable to resolve variation id."); + } + return variationId; + } + + throw Error("Unable to select options they were not defined."); +} diff --git a/examples/klevu/e2e/product-details-page.spec.ts b/examples/klevu/e2e/product-details-page.spec.ts new file mode 100644 index 00000000..b65cad74 --- /dev/null +++ b/examples/klevu/e2e/product-details-page.spec.ts @@ -0,0 +1,29 @@ +import { test } from "@playwright/test"; +import { createD2CProductDetailPage } from "./models/d2c-product-detail-page"; +import { client } from "./util/epcc-client"; +import { skipIfMissingCatalog } from "./util/missing-published-catalog"; + +test.describe("Product Details Page", async () => { + test("should add a simple product to cart", async ({ page }) => { + const productDetailPage = createD2CProductDetailPage(page, client); + + /* Go to base product page */ + await productDetailPage.gotoSimpleProduct(); + + /* Add the product to cart */ + await productDetailPage.addProductToCart(); + }); + + test("should add variation product to cart", async ({ page }) => { + const productDetailPage = createD2CProductDetailPage(page, client); + + /* Go to base product page */ + await productDetailPage.gotoVariationsProduct(); + + /* Select the product variations */ + await productDetailPage.gotoProductVariation(); + + /* Add the product to cart */ + await productDetailPage.addProductToCart(); + }); +}); diff --git a/examples/klevu/e2e/product-list-page.spec.ts b/examples/klevu/e2e/product-list-page.spec.ts new file mode 100644 index 00000000..9d193c82 --- /dev/null +++ b/examples/klevu/e2e/product-list-page.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from "@playwright/test"; +import { gateway } from "@elasticpath/js-sdk"; +import { buildSiteNavigation } from "../src/lib/build-site-navigation"; + +const host = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL; +const client_id = process.env.NEXT_PUBLIC_EPCC_CLIENT_ID; + +const client = gateway({ + client_id, + host, +}); + +test("should be able to use quick view to view full product details", async ({ + page, + isMobile, +}) => { + /* Go to home page */ + await page.goto("/"); + + /* Get the cart id from the cookie */ + const allCookies = await page.context().cookies(); + const cartId = allCookies.find( + (cookie) => cookie.name === "_store_ep_cart", + )?.value; + + const nav = await buildSiteNavigation(client); + + const firstNavItem = nav[0]; + + if (!firstNavItem) { + test.skip( + true, + "No navigation items found can't test product list page flow", + ); + } + + await page.getByRole("button", {name: "Shop Now"}).click(); + + /* Check to make sure the page has navigated to the product list page for Men's / T-Shirts */ + await expect(page).toHaveURL(`/search`); + + await page.locator('[href*="/products/"]').first().click(); + + /* Check to make sure the page has navigated to the product details page for Simple T-Shirt */ + await page.waitForURL(/\/products\//); +}); diff --git a/examples/klevu/e2e/util/enter-payment-information.ts b/examples/klevu/e2e/util/enter-payment-information.ts new file mode 100644 index 00000000..738196b7 --- /dev/null +++ b/examples/klevu/e2e/util/enter-payment-information.ts @@ -0,0 +1,10 @@ +import { Page } from "@playwright/test"; +import { fillAllFormFields, FormInput } from "./fill-form-field"; + +export async function enterPaymentInformation(page: Page, values: FormInput) { + const paymentIframe = await page + .locator('[id="payment-element"]') + .frameLocator("iframe"); + + await fillAllFormFields(paymentIframe, values); +} diff --git a/examples/klevu/e2e/util/epcc-admin-client.ts b/examples/klevu/e2e/util/epcc-admin-client.ts new file mode 100644 index 00000000..5fefa8f9 --- /dev/null +++ b/examples/klevu/e2e/util/epcc-admin-client.ts @@ -0,0 +1,14 @@ +import { gateway, MemoryStorageFactory } from "@elasticpath/js-sdk"; + +const host = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL; +const client_id = process.env.NEXT_PUBLIC_EPCC_CLIENT_ID; +const client_secret = process.env.EPCC_CLIENT_SECRET; + +export const adminClient = gateway({ + client_id, + client_secret, + host, + throttleEnabled: true, + name: "admin_client", + storage: new MemoryStorageFactory(), +}); diff --git a/examples/klevu/e2e/util/epcc-client.ts b/examples/klevu/e2e/util/epcc-client.ts new file mode 100644 index 00000000..e094235c --- /dev/null +++ b/examples/klevu/e2e/util/epcc-client.ts @@ -0,0 +1,12 @@ +import { gateway, MemoryStorageFactory } from "@elasticpath/js-sdk"; + +const host = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL; +const client_id = process.env.NEXT_PUBLIC_EPCC_CLIENT_ID; + +export const client = gateway({ + client_id, + host, + throttleEnabled: true, + name: "implicit_client", + storage: new MemoryStorageFactory(), +}); diff --git a/examples/klevu/e2e/util/fill-form-field.ts b/examples/klevu/e2e/util/fill-form-field.ts new file mode 100644 index 00000000..92c6ce20 --- /dev/null +++ b/examples/klevu/e2e/util/fill-form-field.ts @@ -0,0 +1,48 @@ +import { FrameLocator, Page } from "@playwright/test"; + +export type FormInputValue = { + value: string; + fieldType: "input" | "select" | "combobox"; + options?: { exact?: boolean }; +}; +export type FormInput = Record; + +export async function fillAllFormFields( + page: Page | FrameLocator, + input: FormInput, +) { + const fillers = Object.keys(input).map((key) => { + return () => fillFormField(page, key, input[key]); + }); + + for (const filler of fillers) { + await filler(); + } +} + +export async function fillFormField( + page: Page | FrameLocator, + key: string, + { value, fieldType, options }: FormInputValue, +): Promise { + let locator; + if (fieldType === "combobox") { + locator = page.getByRole("combobox"); + } else { + locator = page.getByLabel(key, { exact: true, ...options }); + } + + switch (fieldType) { + case "input": + return locator.fill(value); + case "select": { + await locator.selectOption(value); + return; + } + case "combobox": { + await locator.click(); + await page.getByLabel(value).click(); + return; + } + } +} diff --git a/examples/klevu/e2e/util/gateway-check.ts b/examples/klevu/e2e/util/gateway-check.ts new file mode 100644 index 00000000..ceacf531 --- /dev/null +++ b/examples/klevu/e2e/util/gateway-check.ts @@ -0,0 +1,13 @@ +import type {ElasticPath } from "@elasticpath/js-sdk"; + +export async function gatewayCheck(client: ElasticPath): Promise { + try { + const gateways = await client.Gateways.All(); + const epPaymentGateway = gateways.data.find( + (gateway) => gateway.slug === "elastic_path_payments_stripe", + )?.enabled; + return !!epPaymentGateway; + } catch (err) { + return false; + } +} diff --git a/examples/klevu/e2e/util/gateway-is-enabled.ts b/examples/klevu/e2e/util/gateway-is-enabled.ts new file mode 100644 index 00000000..12e55d3a --- /dev/null +++ b/examples/klevu/e2e/util/gateway-is-enabled.ts @@ -0,0 +1,10 @@ +import { test } from "@playwright/test"; +import { gatewayCheck } from "./gateway-check"; +import { adminClient } from "./epcc-admin-client"; + +export async function gatewayIsEnabled(): Promise { + test.skip( + !(await gatewayCheck(adminClient)), + "Skipping tests because they payment gateway is not enabled.", + ); +} diff --git a/examples/klevu/e2e/util/get-cart-id.ts b/examples/klevu/e2e/util/get-cart-id.ts new file mode 100644 index 00000000..e2e7dc8d --- /dev/null +++ b/examples/klevu/e2e/util/get-cart-id.ts @@ -0,0 +1,14 @@ +import { expect, Page } from "@playwright/test"; + +export function getCartId(page: Page) { + return async function _getCartId(): Promise { + /* Get the cart id from the cookie */ + const allCookies = await page.context().cookies(); + const cartId = allCookies.find( + (cookie) => cookie.name === "_store_ep_cart", + )?.value; + + expect(cartId).toBeDefined(); + return cartId!; + }; +} diff --git a/examples/klevu/e2e/util/has-published-catalog.ts b/examples/klevu/e2e/util/has-published-catalog.ts new file mode 100644 index 00000000..983fc901 --- /dev/null +++ b/examples/klevu/e2e/util/has-published-catalog.ts @@ -0,0 +1,12 @@ +import type {ElasticPath } from "@elasticpath/js-sdk"; + +export async function hasPublishedCatalog( + client: ElasticPath, +): Promise { + try { + await client.ShopperCatalog.Get(); + return false; + } catch (err) { + return true; + } +} diff --git a/examples/klevu/e2e/util/missing-published-catalog.ts b/examples/klevu/e2e/util/missing-published-catalog.ts new file mode 100644 index 00000000..d48fec22 --- /dev/null +++ b/examples/klevu/e2e/util/missing-published-catalog.ts @@ -0,0 +1,10 @@ +import { test } from "@playwright/test"; +import { hasPublishedCatalog } from "./has-published-catalog"; +import { client } from "./epcc-client"; + +export async function skipIfMissingCatalog(): Promise { + test.skip( + await hasPublishedCatalog(client), + "Skipping tests because there is no published catalog.", + ); +} diff --git a/examples/klevu/e2e/util/resolver-product-from-store.ts b/examples/klevu/e2e/util/resolver-product-from-store.ts new file mode 100644 index 00000000..3005dd7c --- /dev/null +++ b/examples/klevu/e2e/util/resolver-product-from-store.ts @@ -0,0 +1,84 @@ +import type { + ElasticPath, + ShopperCatalogResourcePage, + ProductResponse, + ShopperCatalogResource, +} from "@elasticpath/js-sdk"; + +export async function getSimpleProduct( + client: ElasticPath, +): Promise { + const paginator = paginateShopperProducts(client, { limit: 100 }); + + if (paginator) { + for await (const page of paginator) { + const simpleProduct = page.data.find( + (x) => !x.attributes.base_product && !x.attributes.base_product_id, + ); + if (simpleProduct) { + return simpleProduct; + } + } + } +} + +export async function getProductById( + client: ElasticPath, + productId: string, +): Promise> { + return client.ShopperCatalog.Products.Get({ + productId: productId, + }); +} + +export async function getVariationsProduct( + client: ElasticPath, +): Promise { + const paginator = paginateShopperProducts(client, { limit: 100 }); + + if (paginator) { + for await (const page of paginator) { + const variationsProduct = page.data.find( + (x) => x.attributes.base_product, + ); + if (variationsProduct) { + return variationsProduct; + } + } + } +} + +const makePagedClientRequest = async ( + client: ElasticPath, + { limit = 100, offset }: { limit?: number; offset: number }, +): Promise> => { + return await client.ShopperCatalog.Products.Offset(offset).Limit(limit).All(); +}; + +export type Paginator = AsyncGenerator; + +export async function* paginateShopperProducts( + client: ElasticPath, + input: { limit?: number; offset?: number }, +): Paginator> | undefined { + let page: ShopperCatalogResourcePage; + + let nextOffset: number = input.offset ?? 0; + let hasNext = true; + + while (hasNext) { + page = await makePagedClientRequest(client, { + limit: input.limit, + offset: nextOffset, + }); + yield page; + const { + results: { total: totalItems }, + page: { current, limit }, + } = page.meta; + hasNext = current * limit < totalItems; + nextOffset = nextOffset + limit; + } + + return undefined; +} diff --git a/examples/klevu/e2e/util/skip-ci-env.ts b/examples/klevu/e2e/util/skip-ci-env.ts new file mode 100644 index 00000000..1f93e2d5 --- /dev/null +++ b/examples/klevu/e2e/util/skip-ci-env.ts @@ -0,0 +1,8 @@ +import { test } from "@playwright/test"; + +export function skipIfCIEnvironment(): void { + test.skip( + process.env.CI === "true", + "Skipping tests because we are in a CI environment.", + ); +} diff --git a/examples/klevu/license.md b/examples/klevu/license.md new file mode 100644 index 00000000..714fa3a8 --- /dev/null +++ b/examples/klevu/license.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Elastic Path Software Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/klevu/next-env.d.ts b/examples/klevu/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/examples/klevu/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/examples/klevu/next.config.js b/examples/klevu/next.config.js new file mode 100644 index 00000000..586e89a7 --- /dev/null +++ b/examples/klevu/next.config.js @@ -0,0 +1,38 @@ +// @ts-check + +/** + * @type {import('next').NextConfig} + **/ +const nextConfig = { + images: { + formats: ["image/avif", "image/webp"], + remotePatterns: [ + { + protocol: "https", + hostname: "**.epusercontent.com", + }, + { + protocol: "https", + hostname: "**.cm.elasticpath.com", + }, + ], + }, + i18n: { + locales: ["en"], + defaultLocale: "en", + }, + webpack(config) { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + }; + + return config; + }, +}; + +const withBundleAnalyzer = require("@next/bundle-analyzer")({ + enabled: process.env.ANALYZE === "true", +}); + +module.exports = withBundleAnalyzer(nextConfig); diff --git a/examples/klevu/package.json b/examples/klevu/package.json new file mode 100644 index 00000000..bcdfac27 --- /dev/null +++ b/examples/klevu/package.json @@ -0,0 +1,93 @@ +{ + "name": "klevu", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "lint:fix": "next lint --fix", + "format:check": "prettier --check .", + "format:fix": "prettier --write .", + "type:check": "tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest", + "test:ci:e2e": "NODE_ENV=test pnpm build && (pnpm start & (sleep 5 && npx playwright install --with-deps && pnpm test:e2e && kill $(lsof -t -i tcp:3000)))", + "test:e2e": "NODE_ENV=test playwright test", + "build:e2e": "NODE_ENV=test next build", + "start:e2e": "NODE_ENV=test next start" + }, + "dependencies": { + "@elasticpath/js-sdk": "5.0.0", + "@elasticpath/react-shopper-hooks": "workspace:*", + "@floating-ui/react": "^0.26.3", + "@headlessui/react": "^1.7.17", + "@heroicons/react": "^2.0.18", + "@hookform/error-message": "^2.0.1", + "@hookform/resolvers": "^3.3.2", + "@klevu/core": "5.2.2", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@tanstack/react-query": "^5.51.23", + "@types/lodash": "4.17.7", + "class-variance-authority": "^0.7.0", + "clsx": "^1.2.1", + "cookies-next": "^4.0.0", + "focus-visible": "^5.2.0", + "formik": "^2.2.9", + "lodash": "4.17.21", + "next": "^14.0.0", + "pure-react-carousel": "^1.29.0", + "rc-slider": "^10.3.0", + "react": "^18.3.1", + "react-device-detect": "^2.2.2", + "react-dom": "^18.3.1", + "react-hook-form": "^7.49.0", + "react-toastify": "^9.1.3", + "server-only": "^0.0.1", + "tailwind-clip-path": "^1.0.0", + "tailwind-merge": "^2.0.0", + "zod": "^3.22.4", + "zod-formik-adapter": "^1.2.0" + }, + "devDependencies": { + "@babel/core": "^7.18.10", + "@next/bundle-analyzer": "^14.0.0", + "@next/env": "^14.0.0", + "@svgr/webpack": "^6.3.1", + "@types/node": "18.7.3", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "babel-loader": "^8.2.5", + "eslint": "^8.49.0", + "eslint-config-next": "^14.0.0", + "eslint-config-prettier": "^9.0.0", + "encoding": "^0.1.13", + "eslint-plugin-react": "^7.33.2", + "vite": "^4.2.1", + "vitest": "^0.34.5", + "@vitest/coverage-istanbul": "^0.34.5", + "@testing-library/jest-dom": "^6.1.3", + "@testing-library/react": "^14.0.0", + "@playwright/test": "^1.28.1", + "lint-staged": "^13.0.3", + "prettier": "^3.0.3", + "prettier-eslint": "^15.0.1", + "prettier-eslint-cli": "^7.1.0", + "typescript": "^5.2.2", + "tailwindcss": "^3.3.3", + "autoprefixer": "^10.4.14", + "postcss": "^8.4.30", + "prettier-plugin-tailwindcss": "^0.5.4", + "@tailwindcss/forms": "^0.5.7", + "tailwindcss-animate": "^1.0.7" + } +} \ No newline at end of file diff --git a/examples/klevu/playwright.config.ts b/examples/klevu/playwright.config.ts new file mode 100644 index 00000000..86a23119 --- /dev/null +++ b/examples/klevu/playwright.config.ts @@ -0,0 +1,72 @@ +import { PlaywrightTestConfig, devices } from "@playwright/test"; +import { join } from "path"; +import { loadEnvConfig } from "@next/env"; + +loadEnvConfig(process.env.PWD!); + +// Use process.env.PORT by default and fallback to port 3000 +const PORT = process.env.PORT || 3000; + +// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port +const baseURL = process.env.BASE_URL ?? `http://localhost:${PORT}`; + +// Reference: https://playwright.dev/docs/test-configuration +const config: PlaywrightTestConfig = { + // Timeout per test + timeout: 15 * 1000, + // Test directory + testDir: join(__dirname, "e2e"), + // If a test fails, retry it additional 2 times + retries: 2, + // Artifacts folder where screenshots, videos, and traces are stored. + outputDir: "test-results/", + + // Run your local dev server before starting the tests: + // https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests + // webServer: { + // command: 'yarn run dev', + // url: baseURL, + // timeout: 120 * 1000, + // reuseExistingServer: !process.env.CI, + // }, + + use: { + // Use baseURL so to make navigations relative. + // More information: https://playwright.dev/docs/api/class-testoptions#test-options-base-url + baseURL, + + screenshot: "only-on-failure", + + // Retry a test if its failing with enabled tracing. This allows you to analyse the DOM, console logs, network traffic etc. + // More information: https://playwright.dev/docs/trace-viewer + trace: "retry-with-trace", + + // All available context options: https://playwright.dev/docs/api/class-browser#browser-new-context + // contextOptions: { + // ignoreHTTPSErrors: true, + // }, + }, + + projects: [ + { + name: "Desktop Chrome", + use: { + ...devices["Desktop Chrome"], + }, + }, + { + name: "Desktop Firefox", + use: { + ...devices["Desktop Firefox"], + }, + }, + // Test against mobile viewports. + { + name: "Mobile Chrome", + use: { + ...devices["Pixel 5"], + }, + }, + ], +}; +export default config; diff --git a/examples/klevu/postcss.config.js b/examples/klevu/postcss.config.js new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/examples/klevu/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/examples/klevu/public/favicon.ico b/examples/klevu/public/favicon.ico new file mode 100644 index 00000000..a61f60f1 Binary files /dev/null and b/examples/klevu/public/favicon.ico differ diff --git a/examples/klevu/src/app/(auth)/account-member-credentials-schema.ts b/examples/klevu/src/app/(auth)/account-member-credentials-schema.ts new file mode 100644 index 00000000..19e7a5fc --- /dev/null +++ b/examples/klevu/src/app/(auth)/account-member-credentials-schema.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export const accountMemberCredentialSchema = z.object({ + account_id: z.string(), + account_name: z.string(), + expires: z.string(), + token: z.string(), + type: z.literal("account_management_authentication_token"), +}); + +export type AccountMemberCredential = z.infer< + typeof accountMemberCredentialSchema +>; + +export const accountMemberCredentialsSchema = z.object({ + accounts: z.record(z.string(), accountMemberCredentialSchema), + selected: z.string(), + accountMemberId: z.string(), +}); + +export type AccountMemberCredentials = z.infer< + typeof accountMemberCredentialsSchema +>; diff --git a/examples/klevu/src/app/(auth)/actions.ts b/examples/klevu/src/app/(auth)/actions.ts new file mode 100644 index 00000000..0e18f0d6 --- /dev/null +++ b/examples/klevu/src/app/(auth)/actions.ts @@ -0,0 +1,200 @@ +"use server"; + +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { z } from "zod"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../lib/cookie-constants"; +import { AccountTokenBase, ResourcePage } from "@elasticpath/js-sdk"; +import { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; +import { + AccountMemberCredential, + AccountMemberCredentials, +} from "./account-member-credentials-schema"; +import { retrieveAccountMemberCredentials } from "../../lib/retrieve-account-member-credentials"; +import { revalidatePath } from "next/cache"; +import { getErrorMessage } from "../../lib/get-error-message"; + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string(), + returnUrl: z.string().optional(), +}); + +const selectedAccountSchema = z.object({ + accountId: z.string(), +}); + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string(), + name: z.string(), +}); + +const PASSWORD_PROFILE_ID = process.env.NEXT_PUBLIC_PASSWORD_PROFILE_ID!; + +const loginErrorMessage = + "Failed to login, make sure your email and password are correct"; + +export async function login(props: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(props.entries()); + + const validatedProps = loginSchema.safeParse(rawEntries); + + if (!validatedProps.success) { + return { + error: loginErrorMessage, + }; + } + + const { email, password, returnUrl } = validatedProps.data; + + try { + const result = await client.AccountMembers.GenerateAccountToken({ + type: "account_management_authentication_token", + authentication_mechanism: "password", + password_profile_id: PASSWORD_PROFILE_ID, + username: email.toLowerCase(), // Known bug for uppercase usernames so we force lowercase. + password, + }); + + const cookieStore = cookies(); + cookieStore.set(createCookieFromGenerateTokenResponse(result)); + } catch (error) { + console.error(getErrorMessage(error)); + return { + error: loginErrorMessage, + }; + } + + redirect(returnUrl ?? "/"); +} + +export async function logout() { + const cookieStore = cookies(); + + cookieStore.delete(ACCOUNT_MEMBER_TOKEN_COOKIE_NAME); + + redirect("/"); +} + +export async function selectedAccount(args: FormData) { + const rawEntries = Object.fromEntries(args.entries()); + + const validatedProps = selectedAccountSchema.safeParse(rawEntries); + + if (!validatedProps.success) { + throw new Error("Invalid account id"); + } + + const { accountId } = validatedProps.data; + + const cookieStore = cookies(); + + const accountMemberCredentials = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCredentials) { + redirect("/login"); + return; + } + + const selectedAccount = accountMemberCredentials?.accounts[accountId]; + + if (!selectedAccount) { + throw new Error("Invalid account id"); + } + + cookieStore.set({ + name: ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + value: JSON.stringify({ + ...accountMemberCredentials, + selected: selectedAccount.account_id, + }), + path: "/", + sameSite: "strict", + expires: new Date( + accountMemberCredentials.accounts[ + Object.keys(accountMemberCredentials.accounts)[0] + ].expires, + ), + }); + + revalidatePath("/account"); +} + +export async function register(data: FormData) { + const client = getServerSideImplicitClient(); + + const validatedProps = registerSchema.safeParse( + Object.fromEntries(data.entries()), + ); + + if (!validatedProps.success) { + throw new Error("Invalid email or password or name"); + } + + const { email, password, name } = validatedProps.data; + + const result = await client.AccountMembers.GenerateAccountToken({ + type: "account_management_authentication_token", + authentication_mechanism: "self_signup", + password_profile_id: PASSWORD_PROFILE_ID, + username: email.toLowerCase(), // Known bug for uppercase usernames so we force lowercase. + password, + name, // TODO update sdk types as name should exist + email, + } as any); + + const cookieStore = cookies(); + cookieStore.set(createCookieFromGenerateTokenResponse(result)); + + redirect("/"); +} + +function createCookieFromGenerateTokenResponse( + response: ResourcePage, +): ResponseCookie { + const { expires } = response.data[0]; // assuming all tokens have shared expiration date/time + + const cookieValue = createAccountMemberCredentialsCookieValue( + response.data, + (response.meta as unknown as { account_member_id: string }) + .account_member_id, // TODO update sdk types + ); + + return { + name: ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + value: JSON.stringify(cookieValue), + path: "/", + sameSite: "strict", + expires: new Date(expires), + }; +} + +function createAccountMemberCredentialsCookieValue( + responseTokens: AccountTokenBase[], + accountMemberId: string, +): AccountMemberCredentials { + return { + accounts: responseTokens.reduce( + (acc, responseToken) => ({ + ...acc, + [responseToken.account_id]: { + account_id: responseToken.account_id, + account_name: responseToken.account_name, + expires: responseToken.expires, + token: responseToken.token, + type: "account_management_authentication_token" as const, + }, + }), + {} as Record, + ), + selected: responseTokens[0].account_id, + accountMemberId, + }; +} diff --git a/examples/klevu/src/app/(auth)/layout.tsx b/examples/klevu/src/app/(auth)/layout.tsx new file mode 100644 index 00000000..b655a9bc --- /dev/null +++ b/examples/klevu/src/app/(auth)/layout.tsx @@ -0,0 +1,51 @@ +import { Inter } from "next/font/google"; +import { ReactNode } from "react"; +import { getStoreInitialState } from "../../lib/get-store-initial-state"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { Providers } from "../providers"; +import clsx from "clsx"; + +const { SITE_NAME } = process.env; +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +export const metadata = { + metadataBase: new URL(baseUrl), + title: { + default: SITE_NAME!, + template: `%s | ${SITE_NAME}`, + }, + robots: { + follow: true, + index: true, + }, +}; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export default async function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + const client = getServerSideImplicitClient(); + const initialState = await getStoreInitialState(client); + + return ( + + + {/* headless ui needs this div - https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1745272229 */} +
    + +
    {children}
    +
    +
    + + + ); +} diff --git a/examples/klevu/src/app/(auth)/login/LoginForm.tsx b/examples/klevu/src/app/(auth)/login/LoginForm.tsx new file mode 100644 index 00000000..a89c4a5c --- /dev/null +++ b/examples/klevu/src/app/(auth)/login/LoginForm.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { login } from "../actions"; +import { Label } from "../../../components/label/Label"; +import { Input } from "../../../components/input/Input"; +import { FormStatusButton } from "../../../components/button/FormStatusButton"; +import { useState } from "react"; + +export function LoginForm({ returnUrl }: { returnUrl?: string }) { + const [error, setError] = useState(undefined); + + async function loginAction(formData: FormData) { + const result = await login(formData); + + if ("error" in result) { + setError(result.error); + } + } + + return ( +
    +
    + +
    + +
    +
    + +
    + {returnUrl && ( + + )} + {error && ( +
    + {error} +
    + )} + +
    + Login +
    +
    + ); +} diff --git a/examples/klevu/src/app/(auth)/login/page.tsx b/examples/klevu/src/app/(auth)/login/page.tsx new file mode 100644 index 00000000..7f814ed2 --- /dev/null +++ b/examples/klevu/src/app/(auth)/login/page.tsx @@ -0,0 +1,49 @@ +import EpLogo from "../../../components/icons/ep-logo"; +import { cookies } from "next/headers"; +import { isAccountMemberAuthenticated } from "../../../lib/is-account-member-authenticated"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { LoginForm } from "./LoginForm"; + +export default function Login({ + searchParams, +}: { + searchParams: { returnUrl?: string }; +}) { + const { returnUrl } = searchParams; + + const cookieStore = cookies(); + + if (isAccountMemberAuthenticated(cookieStore)) { + redirect("/account/summary"); + } + + return ( + <> +
    +
    + + + +

    + Sign in to your account +

    +
    + +
    + + +

    + Not a member?{" "} + + Register now! + +

    +
    +
    + + ); +} diff --git a/examples/klevu/src/app/(auth)/not-found.tsx b/examples/klevu/src/app/(auth)/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/klevu/src/app/(auth)/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
    + + 404 - The page could not be found. + + + Back to home + +
    + ); +} diff --git a/examples/klevu/src/app/(auth)/register/page.tsx b/examples/klevu/src/app/(auth)/register/page.tsx new file mode 100644 index 00000000..125ad4e6 --- /dev/null +++ b/examples/klevu/src/app/(auth)/register/page.tsx @@ -0,0 +1,97 @@ +import { register } from "../actions"; +import EpLogo from "../../../components/icons/ep-logo"; +import { cookies } from "next/headers"; +import { isAccountMemberAuthenticated } from "../../../lib/is-account-member-authenticated"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { Label } from "../../../components/label/Label"; +import { Input } from "../../../components/input/Input"; +import { FormStatusButton } from "../../../components/button/FormStatusButton"; + +export default function Register() { + const cookieStore = cookies(); + if (isAccountMemberAuthenticated(cookieStore)) { + redirect("/account/summary"); + } + + return ( + <> +
    +
    + + + +

    + Register for an account +

    +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    + +
    +
    + + +
    +
    + +
    +
    + +
    + Register +
    +
    + +

    + Already a member?{" "} + + Login now! + +

    +
    +
    + + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/AccountCheckout.tsx b/examples/klevu/src/app/(checkout)/checkout/AccountCheckout.tsx new file mode 100644 index 00000000..e85d1651 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/AccountCheckout.tsx @@ -0,0 +1,52 @@ +import Link from "next/link"; +import EpIcon from "../../../components/icons/ep-icon"; +import { Separator } from "../../../components/separator/Separator"; +import { DeliveryForm } from "./DeliveryForm"; +import { PaymentForm } from "./PaymentForm"; +import { BillingForm } from "./BillingForm"; +import { SubmitCheckoutButton } from "./SubmitCheckoutButton"; +import { CheckoutSidebar } from "./CheckoutSidebar"; +import { AccountDisplay } from "./AccountDisplay"; +import { ShippingSelector } from "./ShippingSelector"; + +export function AccountCheckout() { + return ( +
    +
    + + + +
    +
    +
    +
    + + + +
    + +
    + Your Info + +
    +
    + Shipping address + +
    + + +
    + +
    +
    + +
    +
    +
    + {/* Sidebar */} + +
    +
    +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/AccountDisplay.tsx b/examples/klevu/src/app/(checkout)/checkout/AccountDisplay.tsx new file mode 100644 index 00000000..47f400f4 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/AccountDisplay.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useAuthedAccountMember } from "@elasticpath/react-shopper-hooks"; +import { Button } from "../../../components/button/Button"; +import { FormControl, FormField } from "../../../components/form/Form"; +import { Input } from "../../../components/input/Input"; +import React, { useEffect, useTransition } from "react"; +import { useFormContext } from "react-hook-form"; +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { logout } from "../../(auth)/actions"; +import { Skeleton } from "../../../components/skeleton/Skeleton"; + +export function AccountDisplay() { + const { data: accountMember } = useAuthedAccountMember(); + + const { control, setValue } = useFormContext(); + + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + if (accountMember?.email && accountMember?.name) { + setValue("account", { + email: accountMember.email, + name: accountMember.name, + }); + } + }, [accountMember, setValue]); + + return ( +
    +
    + {accountMember ? ( + <> + {accountMember?.name} + {accountMember?.email} + + ) : ( +
    + + +
    + )} +
    + + {accountMember && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )} +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/BillingForm.tsx b/examples/klevu/src/app/(checkout)/checkout/BillingForm.tsx new file mode 100644 index 00000000..0804c54c --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/BillingForm.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { Checkbox } from "../../../components/Checkbox"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { useFormContext, useWatch } from "react-hook-form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/select/Select"; +import { Input } from "../../../components/input/Input"; +import React, { useEffect } from "react"; +import { useCountries } from "../../../hooks/use-countries"; + +export function BillingForm() { + const { control, resetField } = useFormContext(); + const { data: countries } = useCountries(); + const isSameAsShipping = useWatch({ control, name: "sameAsShipping" }); + + useEffect(() => { + // Reset the billing address fields when the user selects the same as shipping address + if (isSameAsShipping) { + resetField("billingAddress", { + keepDirty: false, + keepTouched: false, + keepError: false, + }); + } + }, [isSameAsShipping, resetField]); + + return ( +
    +
    + Billing address +
    +
    + ( + + + + +
    + Same as shipping address +
    +
    + )} + /> +
    + {!isSameAsShipping && ( +
    + ( + + Country + + + + )} + /> +
    + ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
    + ( + + Company (optional) + + + + + + )} + /> + ( + + Address + + + + + + )} + /> +
    + ( + + City + + + + + + )} + /> + ( + + Region + + + + + + )} + /> + ( + + Postcode + + + + + + )} + /> +
    +
    + )} +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/CheckoutFooter.tsx b/examples/klevu/src/app/(checkout)/checkout/CheckoutFooter.tsx new file mode 100644 index 00000000..9882b3a1 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/CheckoutFooter.tsx @@ -0,0 +1,30 @@ +import { Separator } from "../../../components/separator/Separator"; +import Link from "next/link"; +import EpLogo from "../../../components/icons/ep-logo"; +import * as React from "react"; + +export function CheckoutFooter() { + return ( +
    + +
    + + Refund Policy + + + Shipping Policy + + + Privacy Policy + + + Terms of Service + +
    +
    + Powered by + +
    +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/CheckoutSidebar.tsx b/examples/klevu/src/app/(checkout)/checkout/CheckoutSidebar.tsx new file mode 100644 index 00000000..d3e3f4ea --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/CheckoutSidebar.tsx @@ -0,0 +1,103 @@ +"use client"; +import { Separator } from "../../../components/separator/Separator"; +import { CartDiscounts } from "../../../components/cart/CartDiscounts"; +import * as React from "react"; +import { useCart, useCurrencies } from "@elasticpath/react-shopper-hooks"; +import { + ItemSidebarHideable, + ItemSidebarItems, + ItemSidebarPromotions, + ItemSidebarTotals, + ItemSidebarTotalsDiscount, + ItemSidebarTotalsSubTotal, + ItemSidebarTotalsTax, + resolveTotalInclShipping, +} from "../../../components/checkout-sidebar/ItemSidebar"; +import { staticDeliveryMethods } from "./useShippingMethod"; +import { cn } from "../../../lib/cn"; +import { useWatch } from "react-hook-form"; +import { EP_CURRENCY_CODE } from "../../../lib/resolve-ep-currency-code"; +import { formatCurrency } from "../../../lib/format-currency"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export function CheckoutSidebar() { + const { data } = useCart(); + const state = data?.state; + const shippingMethod = useWatch({ name: "shippingMethod" }); + + const { data: currencyData } = useCurrencies(); + + const storeCurrency = currencyData?.find( + (currency) => currency.code === EP_CURRENCY_CODE, + ); + + if (!state) { + return null; + } + + const shippingAmount = staticDeliveryMethods.find( + (method) => method.value === shippingMethod, + )?.amount; + + const { meta, __extended } = state; + + const formattedTotalAmountInclShipping = + meta?.display_price?.with_tax?.amount !== undefined && + shippingAmount !== undefined && + storeCurrency + ? resolveTotalInclShipping( + shippingAmount, + meta.display_price.with_tax.amount, + storeCurrency, + ) + : undefined; + + return ( + +
    + + + + + {/* Totals */} + + +
    + Shipping + + {shippingAmount === undefined ? ( + "Select delivery method" + ) : storeCurrency ? ( + formatCurrency(shippingAmount, storeCurrency) + ) : ( + + )} + +
    + + +
    + + {/* Sum total incl shipping */} + {formattedTotalAmountInclShipping ? ( +
    + Total +
    + {meta?.display_price?.with_tax?.currency} + + {formattedTotalAmountInclShipping} + +
    +
    + ) : ( + + )} +
    +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/CheckoutViews.tsx b/examples/klevu/src/app/(checkout)/checkout/CheckoutViews.tsx new file mode 100644 index 00000000..d88c5115 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/CheckoutViews.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useCheckout } from "./checkout-provider"; +import { ReactNode } from "react"; +import { OrderConfirmation } from "./OrderConfirmation"; + +export function CheckoutViews({ children }: { children: ReactNode }) { + const { confirmationData } = useCheckout(); + + if (confirmationData) { + return ; + } + + return children; +} diff --git a/examples/klevu/src/app/(checkout)/checkout/ConfirmationSidebar.tsx b/examples/klevu/src/app/(checkout)/checkout/ConfirmationSidebar.tsx new file mode 100644 index 00000000..708d1549 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/ConfirmationSidebar.tsx @@ -0,0 +1,108 @@ +"use client"; +import { Separator } from "../../../components/separator/Separator"; +import { CartDiscountsReadOnly } from "../../../components/cart/CartDiscounts"; +import * as React from "react"; +import { + groupCartItems, + useCurrencies, +} from "@elasticpath/react-shopper-hooks"; +import { + ItemSidebarHideable, + ItemSidebarItems, + ItemSidebarTotals, + ItemSidebarTotalsDiscount, + ItemSidebarTotalsSubTotal, + ItemSidebarTotalsTax, + resolveTotalInclShipping, +} from "../../../components/checkout-sidebar/ItemSidebar"; +import { useCheckout } from "./checkout-provider"; +import { staticDeliveryMethods } from "./useShippingMethod"; +import { EP_CURRENCY_CODE } from "../../../lib/resolve-ep-currency-code"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export function ConfirmationSidebar() { + const { confirmationData } = useCheckout(); + + const { data: currencyData } = useCurrencies(); + + if (!confirmationData) { + return null; + } + + const { order, cart } = confirmationData; + + const groupedItems = groupCartItems(cart.data); + + const shippingMethodCustomItem = groupedItems.custom.find((item) => + item.sku.startsWith("__shipping_"), + ); + + const meta = { + display_price: order.data.meta.display_price, + }; + + const shippingAmount = staticDeliveryMethods.find( + (method) => + !!shippingMethodCustomItem && + method.value === shippingMethodCustomItem.sku, + )?.amount; + + const storeCurrency = currencyData?.find( + (currency) => currency.code === EP_CURRENCY_CODE, + ); + + const formattedTotalAmountInclShipping = + meta?.display_price?.with_tax?.amount !== undefined && + shippingAmount !== undefined && + storeCurrency + ? resolveTotalInclShipping( + shippingAmount, + meta.display_price.with_tax.amount, + storeCurrency, + ) + : undefined; + + return ( + +
    +
    + +
    + Discounts applied + + {/* Totals */} + + + {shippingMethodCustomItem && ( +
    + Shipping + + { + shippingMethodCustomItem.meta.display_price.with_tax.value + .formatted + } + +
    + )} + + +
    + + {/* Sum total incl shipping */} + {formattedTotalAmountInclShipping ? ( +
    + Total +
    + {meta?.display_price?.with_tax?.currency} + + {formattedTotalAmountInclShipping} + +
    +
    + ) : ( + + )} +
    +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/DeliveryForm.tsx b/examples/klevu/src/app/(checkout)/checkout/DeliveryForm.tsx new file mode 100644 index 00000000..baa58998 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/DeliveryForm.tsx @@ -0,0 +1,108 @@ +"use client"; +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { + RadioGroup, + RadioGroupItem, +} from "../../../components/radio-group/RadioGroup"; +import { Label } from "../../../components/label/Label"; +import { cn } from "../../../lib/cn"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { useFormContext } from "react-hook-form"; +import { useShippingMethod } from "./useShippingMethod"; +import { LightBulbIcon } from "@heroicons/react/24/outline"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "../../../components/alert/Alert"; +import { Skeleton } from "../../../components/skeleton/Skeleton"; + +export function DeliveryForm() { + const { control } = useFormContext(); + const { data: deliveryOptions } = useShippingMethod(); + + return ( +
    +
    + Delivery +
    + + + Delivery is using fixed rates! + +

    + Delivery is fixed rate data for testing. You can replace this with a + 3rd party service. +

    +
    +
    + {!deliveryOptions ? ( +
    + + +
    + ) : ( + ( + + Shipping Method + + + {deliveryOptions.map((option, optionIndex) => { + return ( +
    +
    + + +
    + {option.formatted} +
    + ); + })} +
    +
    + +
    + )} + /> + )} +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/FormInput.tsx b/examples/klevu/src/app/(checkout)/checkout/FormInput.tsx new file mode 100644 index 00000000..c50a2606 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/FormInput.tsx @@ -0,0 +1,73 @@ +import { ErrorMessage } from "@hookform/error-message"; +import React, { forwardRef, useImperativeHandle, useRef } from "react"; +import { get } from "react-hook-form"; +import { Label } from "../../../components/label/Label"; +import { Input } from "../../../components/input/Input"; +import { cn } from "../../../lib/cn"; + +type InputProps = Omit< + Omit, "size">, + "placeholder" +> & { + label: string; + errors?: Record; + touched?: Record; + name: string; +}; + +export const FormInput = forwardRef( + ({ type, name, label, errors, touched, required, ...props }, ref) => { + const inputRef = useRef(null); + + useImperativeHandle(ref, () => inputRef.current!); + + const hasError = get(errors, name) && get(touched, name); + + return ( +
    +
    +
    +

    + + +

    +
    +
    + {hasError && ( + { + return ( +
    + {message} +
    + ); + }} + /> + )} +
    + ); + }, +); + +FormInput.displayName = "FormInput"; diff --git a/examples/klevu/src/app/(checkout)/checkout/GuestCheckout.tsx b/examples/klevu/src/app/(checkout)/checkout/GuestCheckout.tsx new file mode 100644 index 00000000..8fcb4655 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/GuestCheckout.tsx @@ -0,0 +1,51 @@ +import { GuestInformation } from "./GuestInformation"; +import { ShippingForm } from "./ShippingForm"; +import Link from "next/link"; +import EpIcon from "../../../components/icons/ep-icon"; +import { DeliveryForm } from "./DeliveryForm"; +import { PaymentForm } from "./PaymentForm"; +import { BillingForm } from "./BillingForm"; +import { SubmitCheckoutButton } from "./SubmitCheckoutButton"; +import { Separator } from "../../../components/separator/Separator"; +import * as React from "react"; +import { CheckoutSidebar } from "./CheckoutSidebar"; + +export async function GuestCheckout() { + return ( +
    +
    + + + +
    +
    +
    +
    + + + +
    + +
    + +
    +
    + +
    + + +
    + +
    +
    + +
    +
    +
    + {/* Sidebar */} + +
    +
    +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/GuestInformation.tsx b/examples/klevu/src/app/(checkout)/checkout/GuestInformation.tsx new file mode 100644 index 00000000..fa22e559 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/GuestInformation.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { Input } from "../../../components/input/Input"; +import React from "react"; +import { useFormContext } from "react-hook-form"; + +export function GuestInformation() { + const pathname = usePathname(); + + const { control } = useFormContext(); + + return ( +
    +
    + Your Info + + Already a customer?{" "} + + Sign in + + +
    + ( + + Email + + + + + + )} + /> +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/OrderConfirmation.tsx b/examples/klevu/src/app/(checkout)/checkout/OrderConfirmation.tsx new file mode 100644 index 00000000..0391da30 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/OrderConfirmation.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useCheckout } from "./checkout-provider"; +import { ConfirmationSidebar } from "./ConfirmationSidebar"; +import Link from "next/link"; +import EpIcon from "../../../components/icons/ep-icon"; +import * as React from "react"; +import { Separator } from "../../../components/separator/Separator"; +import { CheckoutFooter } from "./CheckoutFooter"; +import { Button } from "../../../components/button/Button"; + +export function OrderConfirmation() { + const { confirmationData } = useCheckout(); + + if (!confirmationData) { + return null; + } + + const { order } = confirmationData; + + const customerName = ( + order.data.contact?.name ?? + order.data.customer.name ?? + "" + ).split(" ")[0]; + + const { shipping_address, id: orderId } = order.data; + + return ( +
    +
    + + + +
    +
    + {/* Confirmation Content */} +
    +
    + + + +
    + + + Thanks{customerName ? ` ${customerName}` : ""}! + +
    + +
    + + Order #{orderId} is confirmed. + + {/* Shipping */} +
    +

    Ship to

    +
    + {`${shipping_address.first_name} ${shipping_address.last_name}`} +

    {shipping_address.line_1}

    + {`${shipping_address.region}, ${shipping_address.postcode}`} + {shipping_address.phone_number && ( + {shipping_address.phone_number} + )} +
    +
    + {/* Delivery Method */} +
    +

    Delivery Method

    +

    placeholder

    +
    + {/* Contact us */} +
    +

    Need to make changes?

    +

    + Email us or call. Remember to reference order #{orderId} +

    +
    + +
    + {/* Confirmation Sidebar */} +
    +
    + {`Order #${orderId}`} + + +
    +
    +
    +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/PaymentForm.tsx b/examples/klevu/src/app/(checkout)/checkout/PaymentForm.tsx new file mode 100644 index 00000000..ad609d01 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/PaymentForm.tsx @@ -0,0 +1,30 @@ +"use client"; +import { LightBulbIcon } from "@heroicons/react/24/outline"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "../../../components/alert/Alert"; + +export function PaymentForm() { + return ( +
    +
    + Payment +
    + + + Payments set to manual! + +

    + Manual payments are enabled. To test the checkout flow, configure an + alternate payment gateway to take real payments. +

    +

    + To checkout with manual payments you can just complete the order. +

    +
    +
    +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/ShippingForm.tsx b/examples/klevu/src/app/(checkout)/checkout/ShippingForm.tsx new file mode 100644 index 00000000..5f9ab548 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/ShippingForm.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { useFormContext } from "react-hook-form"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { Input } from "../../../components/input/Input"; +import React from "react"; +import { useCountries } from "../../../hooks/use-countries"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/select/Select"; + +export function ShippingForm() { + const { control } = useFormContext(); + const { data: countries } = useCountries(); + + return ( +
    +
    + Shipping address +
    +
    + ( + + Country + + + + )} + /> +
    + ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
    + ( + + Company (optional) + + + + + + )} + /> + ( + + Address + + + + + + )} + /> +
    + ( + + City + + + + + + )} + /> + ( + + Region + + + + + + )} + /> + ( + + Postcode + + + + + + )} + /> +
    + ( + + Phone Number (optional) + + + + + + )} + /> +
    +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/ShippingSelector.tsx b/examples/klevu/src/app/(checkout)/checkout/ShippingSelector.tsx new file mode 100644 index 00000000..91f29d50 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/ShippingSelector.tsx @@ -0,0 +1,99 @@ +"use client"; +import { + useAccountAddresses, + useAuthedAccountMember, +} from "@elasticpath/react-shopper-hooks"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/select/Select"; +import { useFormContext, useWatch } from "react-hook-form"; +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { AccountAddress } from "@elasticpath/js-sdk"; +import { useEffect } from "react"; +import { Skeleton } from "../../../components/skeleton/Skeleton"; +import { Button } from "../../../components/button/Button"; +import Link from "next/link"; + +export function ShippingSelector() { + const { selectedAccountToken } = useAuthedAccountMember(); + + const { data: accountAddressData } = useAccountAddresses( + selectedAccountToken?.account_id!, + { + ep: { accountMemberToken: selectedAccountToken?.token }, + enabled: !!selectedAccountToken, + }, + ); + + const { setValue } = useFormContext(); + + function updateAddress(addressId: string, addresses: AccountAddress[]) { + const address = addresses.find((address) => address.id === addressId); + + if (address) { + setValue("shippingAddress", { + postcode: address.postcode, + line_1: address.line_1, + line_2: address.line_2, + city: address.city, + county: address.county, + country: address.country, + company_name: address.company_name, + first_name: address.first_name, + last_name: address.last_name, + phone_number: address.phone_number, + instructions: address.instructions, + region: address.region, + }); + } + } + + useEffect(() => { + if (accountAddressData && accountAddressData[0]) { + updateAddress(accountAddressData[0].id, accountAddressData); + } + }, [accountAddressData]); + + return ( +
    + {accountAddressData ? ( + + ) : ( + + )} +
    + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx b/examples/klevu/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx new file mode 100644 index 00000000..d67880df --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useCheckout } from "./checkout-provider"; +import { useCart } from "@elasticpath/react-shopper-hooks"; +import { StatusButton } from "../../../components/button/StatusButton"; + +export function SubmitCheckoutButton() { + const { handleSubmit, completePayment, isCompleting } = useCheckout(); + const { data } = useCart(); + + const state = data?.state; + + if (!state) { + return null; + } + + return ( + { + completePayment.mutate({ data: values }); + })} + > + {`Pay ${state.meta?.display_price?.with_tax?.formatted}`} + + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/checkout-provider.tsx b/examples/klevu/src/app/(checkout)/checkout/checkout-provider.tsx new file mode 100644 index 00000000..b9aa8216 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/checkout-provider.tsx @@ -0,0 +1,103 @@ +"use client"; + +import React, { createContext, useContext, useState } from "react"; +import { useForm, useFormContext } from "react-hook-form"; +import { + CheckoutForm, + checkoutFormSchema, +} from "../../../components/checkout/form-schema/checkout-form-schema"; +import { + CartState, + useAuthedAccountMember, + useCart, + useCartClear, +} from "@elasticpath/react-shopper-hooks"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Form } from "../../../components/form/Form"; +import { ShippingMethod, useShippingMethod } from "./useShippingMethod"; +import { usePaymentComplete } from "./usePaymentComplete"; + +type CheckoutContext = { + cart?: CartState; + isLoading: boolean; + completePayment: ReturnType; + isCompleting: boolean; + confirmationData: ReturnType["data"]; + shippingMethods: { + options?: ShippingMethod[]; + isLoading: boolean; + }; +}; + +const CheckoutContext = createContext(null); + +type CheckoutProviderProps = { + children?: React.ReactNode; +}; + +export function CheckoutProvider({ children }: CheckoutProviderProps) { + const { data } = useCart(); + + const state = data?.state; + + const { mutateAsync: mutateClearCart } = useCartClear(); + + const [confirmationData, setConfirmationData] = + useState["data"]>(undefined); + + const formMethods = useForm({ + reValidateMode: "onChange", + resolver: zodResolver(checkoutFormSchema), + defaultValues: { + sameAsShipping: true, + shippingMethod: "__shipping_standard", + }, + }); + + const { selectedAccountToken } = useAuthedAccountMember(); + + const { data: shippingMethods, isLoading: isShippingMethodsLoading } = + useShippingMethod(); + + const paymentComplete = usePaymentComplete( + { + cartId: state?.id, + accountToken: selectedAccountToken?.token, + }, + { + onSuccess: async (data) => { + setConfirmationData(data); + await mutateClearCart(); + }, + }, + ); + + return ( +
    + + {children} + +
    + ); +} + +export const useCheckout = () => { + const context = useContext(CheckoutContext); + const form = useFormContext(); + if (context === null) { + throw new Error("useCheckout must be used within a CheckoutProvider"); + } + return { ...context, ...form }; +}; diff --git a/examples/klevu/src/app/(checkout)/checkout/page.tsx b/examples/klevu/src/app/(checkout)/checkout/page.tsx new file mode 100644 index 00000000..df42df74 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/page.tsx @@ -0,0 +1,44 @@ +import { Metadata } from "next"; +import { AccountCheckout } from "./AccountCheckout"; +import { retrieveAccountMemberCredentials } from "../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../lib/cookie-constants"; +import { GuestCheckout } from "./GuestCheckout"; +import { cookies } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { COOKIE_PREFIX_KEY } from "../../../lib/resolve-cart-env"; +import { getEpccImplicitClient } from "../../../lib/epcc-implicit-client"; +import { CheckoutProvider } from "./checkout-provider"; +import { CheckoutViews } from "./CheckoutViews"; + +export const metadata: Metadata = { + title: "Checkout", +}; +export default async function CheckoutPage() { + const cookieStore = cookies(); + + const cartCookie = cookieStore.get(`${COOKIE_PREFIX_KEY}_ep_cart`); + const client = getEpccImplicitClient(); + + const cart = await client.Cart(cartCookie?.value).With("items").Get(); + + if (!cart) { + notFound(); + } + + if ((cart.included?.items?.length ?? 0) < 1) { + redirect("/cart"); + } + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + return ( + + + {!accountMemberCookie ? : } + + + ); +} diff --git a/examples/klevu/src/app/(checkout)/checkout/usePaymentComplete.tsx b/examples/klevu/src/app/(checkout)/checkout/usePaymentComplete.tsx new file mode 100644 index 00000000..78123154 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/usePaymentComplete.tsx @@ -0,0 +1,129 @@ +import { + useAddCustomItemToCart, + useCheckout as useCheckoutCart, + useCheckoutWithAccount, + usePayments, +} from "@elasticpath/react-shopper-hooks"; +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { CheckoutForm } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { staticDeliveryMethods } from "./useShippingMethod"; +import { + CartItemsResponse, + ConfirmPaymentResponse, + Order, + Resource, +} from "@elasticpath/js-sdk"; + +export type UsePaymentCompleteProps = { + cartId: string | undefined; + accountToken?: string; +}; + +export type UsePaymentCompleteReq = { + data: CheckoutForm; +}; + +export function usePaymentComplete( + { cartId, accountToken }: UsePaymentCompleteProps, + options?: UseMutationOptions< + { + order: Resource; + payment: ConfirmPaymentResponse; + cart: CartItemsResponse; + }, + unknown, + UsePaymentCompleteReq + >, +) { + const { mutateAsync: mutatePayment } = usePayments(); + const { mutateAsync: mutateConvertToOrder } = useCheckoutCart(cartId ?? ""); + const { mutateAsync: mutateConvertToOrderAsAccount } = useCheckoutWithAccount( + cartId ?? "", + ); + const { mutateAsync: mutateAddCustomItemToCart } = useAddCustomItemToCart( + cartId ?? "", + ); + + const paymentComplete = useMutation({ + mutationFn: async ({ data }) => { + const { + shippingAddress, + billingAddress, + sameAsShipping, + shippingMethod, + } = data; + + const customerName = `${shippingAddress.first_name} ${shippingAddress.last_name}`; + + const checkoutProps = { + billingAddress: + billingAddress && !sameAsShipping ? billingAddress : shippingAddress, + shippingAddress: shippingAddress, + }; + + /** + * The handling of shipping options is not production ready. + * You must implement your own based on your business needs. + */ + const shippingAmount = + staticDeliveryMethods.find((method) => method.value === shippingMethod) + ?.amount ?? 0; + + /** + * Using a cart custom_item to represent shipping for demo purposes. + */ + const cartInclShipping = await mutateAddCustomItemToCart({ + type: "custom_item", + name: "Shipping", + sku: shippingMethod, + quantity: 1, + price: { + amount: shippingAmount, + includes_tax: true, + }, + }); + + /** + * 1. Convert our cart to an order we can pay + */ + const createdOrder = await ("guest" in data + ? mutateConvertToOrder({ + customer: { + email: data.guest.email, + name: customerName, + }, + ...checkoutProps, + }) + : mutateConvertToOrderAsAccount({ + contact: { + name: data.account.name, + email: data.account.email, + }, + token: accountToken ?? "", + ...checkoutProps, + })); + + /** + * 2. Perform payment against the order + */ + const confirmedPayment = await mutatePayment({ + orderId: createdOrder.data.id, + payment: { + gateway: "manual", + method: "purchase", + }, + }); + + return { + order: createdOrder, + payment: confirmedPayment, + cart: cartInclShipping, + }; + }, + ...options, + }); + + return { + ...paymentComplete, + }; +} diff --git a/examples/klevu/src/app/(checkout)/checkout/useShippingMethod.tsx b/examples/klevu/src/app/(checkout)/checkout/useShippingMethod.tsx new file mode 100644 index 00000000..9703ec2b --- /dev/null +++ b/examples/klevu/src/app/(checkout)/checkout/useShippingMethod.tsx @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query"; + +export type ShippingMethod = { + label: string; + value: string; + amount: number; + formatted: string; +}; + +export const staticDeliveryMethods: ShippingMethod[] = [ + { + label: "Standard", + value: "__shipping_standard", + amount: 0, + formatted: "Free", + }, + { + label: "Express", + value: "__shipping_express", + amount: 1200, + formatted: "$12.00", + }, +]; + +export function useShippingMethod() { + const deliveryMethods = useQuery({ + queryKey: ["delivery-methods"], + queryFn: () => { + /** + * Replace these with your own delivery methods. You can also fetch them from the API. + */ + return staticDeliveryMethods; + }, + }); + + return { + ...deliveryMethods, + }; +} diff --git a/examples/klevu/src/app/(checkout)/layout.tsx b/examples/klevu/src/app/(checkout)/layout.tsx new file mode 100644 index 00000000..16f80170 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/layout.tsx @@ -0,0 +1,51 @@ +import { Inter } from "next/font/google"; +import { ReactNode } from "react"; +import { getStoreInitialState } from "../../lib/get-store-initial-state"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { Providers } from "../providers"; +import clsx from "clsx"; + +const { SITE_NAME } = process.env; +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +export const metadata = { + metadataBase: new URL(baseUrl), + title: { + default: SITE_NAME!, + template: `%s | ${SITE_NAME}`, + }, + robots: { + follow: true, + index: true, + }, +}; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export default async function CheckoutLayout({ + children, +}: { + children: ReactNode; +}) { + const client = getServerSideImplicitClient(); + const initialState = await getStoreInitialState(client); + + return ( + + + {/* headless ui needs this div - https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1745272229 */} +
    + +
    {children}
    +
    +
    + + + ); +} diff --git a/examples/klevu/src/app/(checkout)/not-found.tsx b/examples/klevu/src/app/(checkout)/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/klevu/src/app/(checkout)/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
    + + 404 - The page could not be found. + + + Back to home + +
    + ); +} diff --git a/examples/klevu/src/app/(store)/about/page.tsx b/examples/klevu/src/app/(store)/about/page.tsx new file mode 100644 index 00000000..fc900ed0 --- /dev/null +++ b/examples/klevu/src/app/(store)/about/page.tsx @@ -0,0 +1,5 @@ +import Blurb from "../../../components/shared/blurb"; + +export default function About() { + return ; +} diff --git a/examples/klevu/src/app/(store)/account/AccountNavigation.tsx b/examples/klevu/src/app/(store)/account/AccountNavigation.tsx new file mode 100644 index 00000000..f3474312 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/AccountNavigation.tsx @@ -0,0 +1,54 @@ +"use client"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Button } from "../../../components/button/Button"; +import { logout } from "../../(auth)/actions"; +import { useTransition } from "react"; + +export function AccountNavigation() { + const pathname = usePathname(); + const [isPending, startTransition] = useTransition(); + + return ( + + ); +} diff --git a/examples/klevu/src/app/(store)/account/addresses/DeleteAddressBtn.tsx b/examples/klevu/src/app/(store)/account/addresses/DeleteAddressBtn.tsx new file mode 100644 index 00000000..2230af9d --- /dev/null +++ b/examples/klevu/src/app/(store)/account/addresses/DeleteAddressBtn.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { deleteAddress } from "./actions"; +import { FormStatusButton } from "../../../../components/button/FormStatusButton"; +import { TrashIcon } from "@heroicons/react/24/outline"; +import React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + accountAddressesQueryKeys, + useAuthedAccountMember, +} from "@elasticpath/react-shopper-hooks"; + +export function DeleteAddressBtn({ addressId }: { addressId: string }) { + const queryClient = useQueryClient(); + const { selectedAccountToken } = useAuthedAccountMember(); + + return ( +
    { + await deleteAddress(formData); + await queryClient.invalidateQueries({ + queryKey: [ + ...accountAddressesQueryKeys.list({ + accountId: selectedAccountToken?.account_id, + }), + ], + }); + }} + > + + } + > + Delete + +
    + ); +} diff --git a/examples/klevu/src/app/(store)/account/addresses/[addressId]/UpdateForm.tsx b/examples/klevu/src/app/(store)/account/addresses/[addressId]/UpdateForm.tsx new file mode 100644 index 00000000..a32c40e1 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/addresses/[addressId]/UpdateForm.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { updateAddress } from "../actions"; +import { Label } from "../../../../../components/label/Label"; +import { Input } from "../../../../../components/input/Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../../../components/select/Select"; +import { FormStatusButton } from "../../../../../components/button/FormStatusButton"; +import React from "react"; +import { countries as staticCountries } from "../../../../../lib/all-countries"; +import { AccountAddress } from "@elasticpath/js-sdk"; +import { + accountAddressesQueryKeys, + useAuthedAccountMember, +} from "@elasticpath/react-shopper-hooks"; +import { useQueryClient } from "@tanstack/react-query"; + +export function UpdateForm({ + addressId, + addressData, +}: { + addressId: string; + addressData: AccountAddress; +}) { + const queryClient = useQueryClient(); + const { selectedAccountToken } = useAuthedAccountMember(); + const countries = staticCountries; + + return ( +
    { + await updateAddress(formData); + await queryClient.invalidateQueries({ + queryKey: [ + ...accountAddressesQueryKeys.list({ + accountId: selectedAccountToken?.account_id, + }), + ], + }); + }} + className="flex flex-col gap-5" + > +
    + +
    +

    + + +

    +
    +
    +

    + + +

    +

    + + +

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + Save changes + +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/account/addresses/[addressId]/page.tsx b/examples/klevu/src/app/(store)/account/addresses/[addressId]/page.tsx new file mode 100644 index 00000000..8dcd4d4f --- /dev/null +++ b/examples/klevu/src/app/(store)/account/addresses/[addressId]/page.tsx @@ -0,0 +1,64 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../../lib/cookie-constants"; +import { getServerSideImplicitClient } from "../../../../../lib/epcc-server-side-implicit-client"; +import { Button } from "../../../../../components/button/Button"; +import Link from "next/link"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import React from "react"; +import { Separator } from "../../../../../components/separator/Separator"; +import { UpdateForm } from "./UpdateForm"; + +export const dynamic = "force-dynamic"; + +export default async function Address({ + params, +}: { + params: { addressId: string }; +}) { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + const { addressId } = params; + + const activeAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + const address = await client.AccountAddresses.Get({ + account: activeAccount.account_id, + address: addressId, + token: activeAccount.token, + }); + + const addressData = address.data; + + return ( +
    +
    + +
    + +
    +

    Edit Address

    + +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/account/addresses/actions.ts b/examples/klevu/src/app/(store)/account/addresses/actions.ts new file mode 100644 index 00000000..a128dad5 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/addresses/actions.ts @@ -0,0 +1,200 @@ +"use server"; + +import { z } from "zod"; +import { cookies } from "next/headers"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { revalidatePath } from "next/cache"; +import { shippingAddressSchema } from "../../../../components/checkout/form-schema/checkout-form-schema"; +import { AccountAddress, Resource } from "@elasticpath/js-sdk"; +import { redirect } from "next/navigation"; + +const deleteAddressSchema = z.object({ + addressId: z.string(), +}); + +const updateAddressSchema = shippingAddressSchema.merge( + z.object({ + name: z.string(), + addressId: z.string(), + line_2: z + .string() + .optional() + .transform((e) => (e === "" ? undefined : e)), + }), +); + +const addAddressSchema = shippingAddressSchema.merge( + z.object({ + name: z.string(), + line_2: z + .string() + .optional() + .transform((e) => (e === "" ? undefined : e)), + }), +); + +export async function deleteAddress(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = deleteAddressSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + throw new Error("Invalid address id"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const { addressId } = validatedFormData.data; + const selectedAccount = getSelectedAccount(accountMemberCreds); + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/accounts/${selectedAccount.account_id}/addresses/${addressId}`, + "DELETE", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + revalidatePath("/accounts/addresses"); + } catch (error) { + console.error(error); + throw new Error("Error deleting address"); + } +} + +export async function updateAddress(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = updateAddressSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid address submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const selectedAccount = getSelectedAccount(accountMemberCreds); + + const { addressId, ...addressData } = validatedFormData.data; + + const body = { + data: { + type: "address", + id: addressId, + ...addressData, + }, + }; + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/accounts/${selectedAccount.account_id}/addresses/${addressId}`, + "PUT", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + revalidatePath("/accounts/addresses"); + } catch (error) { + console.error(error); + throw new Error("Error updating address"); + } +} + +export async function addAddress(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = addAddressSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid address submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const selectedAccount = getSelectedAccount(accountMemberCreds); + + const { ...addressData } = validatedFormData.data; + + const body = { + data: { + type: "address", + ...addressData, + }, + }; + + let redirectUrl: string | undefined = undefined; + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + const result = (await client.request.send( + `/accounts/${selectedAccount.account_id}/addresses`, + "POST", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + )) as Resource; + + redirectUrl = `/account/addresses/${result.data.id}`; + } catch (error) { + console.error(error); + throw new Error("Error adding address"); + } + + revalidatePath("/account/addresses"); + redirect(redirectUrl); +} diff --git a/examples/klevu/src/app/(store)/account/addresses/add/AddForm.tsx b/examples/klevu/src/app/(store)/account/addresses/add/AddForm.tsx new file mode 100644 index 00000000..08339c29 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/addresses/add/AddForm.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { addAddress } from "../actions"; +import { Label } from "../../../../../components/label/Label"; +import { Input } from "../../../../../components/input/Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../../../components/select/Select"; +import { FormStatusButton } from "../../../../../components/button/FormStatusButton"; +import React from "react"; +import { countries as staticCountries } from "../../../../../lib/all-countries"; +import { useQueryClient } from "@tanstack/react-query"; +import { + accountAddressesQueryKeys, + useAuthedAccountMember, +} from "@elasticpath/react-shopper-hooks"; + +export function AddForm() { + const queryClient = useQueryClient(); + const { selectedAccountToken } = useAuthedAccountMember(); + const countries = staticCountries; + + return ( +
    { + await addAddress(formData); + await queryClient.invalidateQueries({ + queryKey: [ + ...accountAddressesQueryKeys.list({ + accountId: selectedAccountToken?.account_id, + }), + ], + }); + }} + className="flex flex-col gap-5" + > +
    +
    +

    + + +

    +
    +
    +

    + + +

    +

    + + +

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + Save changes + +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/account/addresses/add/page.tsx b/examples/klevu/src/app/(store)/account/addresses/add/page.tsx new file mode 100644 index 00000000..479aed5b --- /dev/null +++ b/examples/klevu/src/app/(store)/account/addresses/add/page.tsx @@ -0,0 +1,43 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { retrieveAccountMemberCredentials } from "../../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../../lib/cookie-constants"; +import { Button } from "../../../../../components/button/Button"; +import Link from "next/link"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import React from "react"; +import { Separator } from "../../../../../components/separator/Separator"; +import { AddForm } from "./AddForm"; + +export const dynamic = "force-dynamic"; + +export default async function AddAddress() { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + return ( +
    +
    + +
    + +
    +

    Add Address

    + +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/account/addresses/page.tsx b/examples/klevu/src/app/(store)/account/addresses/page.tsx new file mode 100644 index 00000000..97e0ffa3 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/addresses/page.tsx @@ -0,0 +1,90 @@ +import { + PencilSquareIcon, + PlusIcon, +} from "@heroicons/react/24/outline"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { redirect } from "next/navigation"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import Link from "next/link"; +import { Button } from "../../../../components/button/Button"; +import { Separator } from "../../../../components/separator/Separator"; +import React from "react"; +import { DeleteAddressBtn } from "./DeleteAddressBtn"; + +export const dynamic = "force-dynamic"; + +export default async function Addresses() { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + const addresses = await client.AccountAddresses.All({ + account: selectedAccount.account_id, + token: selectedAccount.token, + }); + + return ( +
    +
    +

    Your info

    +
      + {addresses.data.map((address) => ( +
    • +
      +
      +

      + {address.name} +

      +
      +
      +

      + {address.first_name} {address.last_name} +

      +

      {address.line_1}

      +

      {address.postcode}

      +
      +
      +
      + + +
      +
    • + ))} +
    +
    + +
    + +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/account/breadcrumb.tsx b/examples/klevu/src/app/(store)/account/breadcrumb.tsx new file mode 100644 index 00000000..1ca7add5 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/breadcrumb.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { HomeIcon } from "@heroicons/react/20/solid"; +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import clsx from "clsx"; + +interface BreadcrumbItem { + label: string; + href?: string; + current: boolean; +} + +export function Breadcrumb() { + const pathname = usePathname(); + + const pathnameParts = pathname.split("/").filter(Boolean); + + // Function to convert pathname to breadcrumb items + const getBreadcrumbItems = (pathname: string): BreadcrumbItem[] => { + const pathSegments = pathname + .split("/") + .filter((segment) => segment !== ""); + + return pathSegments.map((segment, index) => { + const href = + segment === "account" + ? undefined + : `/${pathSegments.slice(0, index + 1).join("/")}`; + return { + label: segment, + href, + current: index === pathSegments.length - 1, // Set current to true for the last segment + }; + }); + }; + + const breadcrumbItems = getBreadcrumbItems(pathname); + + return ( + + ); +} diff --git a/examples/klevu/src/app/(store)/account/layout.tsx b/examples/klevu/src/app/(store)/account/layout.tsx new file mode 100644 index 00000000..2bc085fe --- /dev/null +++ b/examples/klevu/src/app/(store)/account/layout.tsx @@ -0,0 +1,17 @@ +import { ReactNode } from "react"; +import { AccountNavigation } from "./AccountNavigation"; + +export default function AccountLayout({ children }: { children: ReactNode }) { + return ( +
    +
    +
    +
    + +
    +
    {children}
    +
    +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/account/orders/OrderItem.tsx b/examples/klevu/src/app/(store)/account/orders/OrderItem.tsx new file mode 100644 index 00000000..52ff1f43 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/orders/OrderItem.tsx @@ -0,0 +1,70 @@ +import { ReactNode } from "react"; +import { Order, OrderItem as OrderItemType } from "@elasticpath/js-sdk"; +import { ProductThumbnail } from "./[orderId]/ProductThumbnail"; +import Link from "next/link"; +import { formatIsoDateString } from "../../../../lib/format-iso-date-string"; + +export type OrderItemProps = { + children?: ReactNode; + order: Order; + orderItems: OrderItemType[]; + imageUrl?: string; +}; + +export function OrderItem({ children, order, orderItems }: OrderItemProps) { + // Sorted order items are used to determine which image to show + // showing the most expensive item's image + const sortedOrderItems = orderItems.sort( + (a, b) => b.unit_price.amount - a.unit_price.amount, + ); + return ( +
    +
    + + + +
    +
    + + Order # {order.external_ref ?? order.id} + + +

    + {formatOrderItemsTitle(sortedOrderItems)} +

    + + {children} +
    +
    + + {order.meta.display_price.with_tax.formatted} +
    +
    + ); +} + +function formatOrderItemsTitle(orderItems: OrderItemType[]): string { + if (orderItems.length === 0) { + return "No items in the order"; + } + + if (orderItems.length === 1) { + return orderItems[0].name; + } + + const firstTwoItems = orderItems.slice(0, 2).map((item) => item.name); + const remainingItemCount = orderItems.length - 2; + + if (remainingItemCount === 0) { + return `${firstTwoItems.join(" and ")} in the order`; + } + + return `${firstTwoItems.join(", ")} and ${remainingItemCount} other item${ + remainingItemCount > 1 ? "s" : "" + }`; +} diff --git a/examples/klevu/src/app/(store)/account/orders/OrderItemWithDetails.tsx b/examples/klevu/src/app/(store)/account/orders/OrderItemWithDetails.tsx new file mode 100644 index 00000000..4781054b --- /dev/null +++ b/examples/klevu/src/app/(store)/account/orders/OrderItemWithDetails.tsx @@ -0,0 +1,32 @@ +import { OrderItem, OrderItemProps } from "./OrderItem"; +import { formatIsoDateString } from "../../../../lib/format-iso-date-string"; + +export function OrderItemWithDetails(props: Omit) { + const sortedOrderItems = props.orderItems.sort( + (a, b) => b.unit_price.amount - a.unit_price.amount, + ); + + return ( + +
    +
      + {sortedOrderItems.map((item) => ( +
    • + {item.quantity} × {item.name} +
    • + ))} +
    +
    +
    + Ordered + +
    +
    + + ); +} diff --git a/examples/klevu/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx b/examples/klevu/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx new file mode 100644 index 00000000..b5295d60 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx @@ -0,0 +1,42 @@ +import { ProductThumbnail } from "./ProductThumbnail"; +import { OrderItem } from "@elasticpath/js-sdk"; +import Link from "next/link"; + +export function OrderLineItem({ orderItem }: { orderItem: OrderItem }) { + return ( +
    +
    + + + +
    +
    +
    + +

    + {orderItem.name} +

    + + + Quantity: {orderItem.quantity} + +
    +
    + + {orderItem.meta?.display_price?.with_tax?.value.formatted} + + {orderItem.meta?.display_price?.without_discount?.value.amount && + orderItem.meta?.display_price?.without_discount?.value.amount !== + orderItem.meta?.display_price?.with_tax?.value.amount && ( + + { + orderItem.meta?.display_price?.without_discount?.value + .formatted + } + + )} +
    +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx b/examples/klevu/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx new file mode 100644 index 00000000..9e8f8d50 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx @@ -0,0 +1,22 @@ +"use client"; +import Image from "next/image"; +import { useProduct } from "@elasticpath/react-shopper-hooks"; + +const gray1pxBase64 = + "data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="; + +export function ProductThumbnail({ productId }: { productId: string }) { + const { data, included } = useProduct({ productId }); + + const imageHref = included?.main_images?.[0]?.link.href; + const title = data?.attributes?.name ?? "Loading..."; + return ( + {title} + ); +} diff --git a/examples/klevu/src/app/(store)/account/orders/[orderId]/page.tsx b/examples/klevu/src/app/(store)/account/orders/[orderId]/page.tsx new file mode 100644 index 00000000..92419c87 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/orders/[orderId]/page.tsx @@ -0,0 +1,206 @@ +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../../lib/cookie-constants"; +import { notFound, redirect } from "next/navigation"; +import { getServerSideImplicitClient } from "../../../../../lib/epcc-server-side-implicit-client"; +import { + Order as OrderType, + OrderIncluded, + OrderItem, + RelationshipToMany, +} from "@elasticpath/js-sdk"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../../lib/retrieve-account-member-credentials"; +import { Button } from "../../../../../components/button/Button"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; +import { formatIsoDateString } from "../../../../../lib/format-iso-date-string"; +import { OrderLineItem } from "./OrderLineItem"; + +export const dynamic = "force-dynamic"; + +export default async function Order({ + params, +}: { + params: { orderId: string }; +}) { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + let result: Awaited> | undefined = + undefined; + try { + result = await client.request.send( + `/orders/${params.orderId}?include=items`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + } catch (e: any) { + if ( + "errors" in e && + (e.errors as any)[0].detail === "The order does not exist" + ) { + notFound(); + } + throw e; + } + + const shopperOrder = result!.included + ? resolveShopperOrder(result!.data, result!.included) + : { raw: result!.data, items: [] }; + + const shippingAddress = shopperOrder.raw.shipping_address; + + const productItems = shopperOrder.items.filter( + (item) => + item.unit_price.amount >= 0 && !item.sku.startsWith("__shipping_"), + ); + const shippingItem = shopperOrder.items.find((item) => + item.sku.startsWith("__shipping_"), + ); + + return ( +
    +
    + +
    +
    +
    +
    +

    Order # {shopperOrder.raw.id}

    + +
    +
    +
    +
    + Shipping address +

    + {shippingAddress.first_name} {shippingAddress.last_name} +
    + {shippingAddress.line_1} +
    + {shippingAddress.city ?? shippingAddress.county},{" "} + {shippingAddress.postcode} {shippingAddress.country} +

    +
    +
    + Shipping status + {shopperOrder.raw.shipping} +
    +
    + Payment status + {shopperOrder.raw.payment} +
    +
    +
    +
      + {productItems.map((item) => ( +
    • + +
    • + ))} +
    +
    +
    +
    +
    + Subtotal + + {shopperOrder.raw.meta.display_price.without_tax.formatted} + +
    +
    + Shipping + + {shippingItem?.meta?.display_price?.with_tax?.value.formatted ?? + shopperOrder.raw.meta.display_price.shipping.formatted} + +
    + {shopperOrder.raw.meta.display_price.discount.amount < 0 && ( +
    + Discount + + {shopperOrder.raw.meta.display_price.discount.formatted} + +
    + )} +
    + Sales Tax + + {shopperOrder.raw.meta.display_price.tax.formatted} + +
    +
    +
    +
    + Total + + {shopperOrder.raw.meta.display_price.with_tax.formatted} + +
    +
    +
    + ); +} + +function resolveOrderItemsFromRelationship( + itemRelationships: RelationshipToMany<"item">["data"], + itemMap: Record, +): OrderItem[] { + return itemRelationships.reduce((orderItems, itemRel) => { + const includedItem: OrderItem | undefined = itemMap[itemRel.id]; + return [...orderItems, ...(includedItem && [includedItem])]; + }, [] as OrderItem[]); +} + +function resolveShopperOrder( + order: OrderType, + included: NonNullable, +): { raw: OrderType; items: OrderItem[] } { + // Create a map of included items by their id + const itemMap = included.items + ? included.items.reduce( + (acc, item) => { + return { ...acc, [item.id]: item }; + }, + {} as Record, + ) + : {}; + + // Map the items in the data array to their corresponding included items + const orderItems = order.relationships?.items?.data + ? resolveOrderItemsFromRelationship(order.relationships.items.data, itemMap) + : []; + + return { + raw: order, + items: orderItems, + }; +} diff --git a/examples/klevu/src/app/(store)/account/orders/page.tsx b/examples/klevu/src/app/(store)/account/orders/page.tsx new file mode 100644 index 00000000..d6109361 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/orders/page.tsx @@ -0,0 +1,128 @@ +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { redirect } from "next/navigation"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { + Order, + OrderItem, + RelationshipToMany, + ResourcePage, +} from "@elasticpath/js-sdk"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { ResourcePagination } from "../../../../components/pagination/ResourcePagination"; +import { DEFAULT_PAGINATION_LIMIT } from "../../../../lib/constants"; +import { OrderItemWithDetails } from "./OrderItemWithDetails"; + +export const dynamic = "force-dynamic"; + +export default async function Orders({ + searchParams, +}: { + searchParams?: { + limit?: string; + offset?: string; + page?: string; + }; +}) { + const currentPage = Number(searchParams?.page) || 1; + const limit = Number(searchParams?.limit) || DEFAULT_PAGINATION_LIMIT; + const offset = Number(searchParams?.offset) || 0; + + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + const result: Awaited> = + await client.request.send( + `/orders?include=items&page[limit]=${limit}&page[offset]=${offset}`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + const mappedOrders = result.included + ? resolveShopperOrder(result.data, result.included) + : []; + + const totalPages = Math.ceil(result.meta.results.total / limit); + + return ( +
    +
    +

    Order history

    +
    +
    +
      + {mappedOrders.map(({ raw: order, items }) => ( +
    • + +
    • + ))} +
    +
    +
    + +
    +
    + ); +} + +function resolveOrderItemsFromRelationship( + itemRelationships: RelationshipToMany<"item">["data"], + itemMap: Record, +): OrderItem[] { + return itemRelationships.reduce((orderItems, itemRel) => { + const includedItem: OrderItem | undefined = itemMap[itemRel.id]; + return [...orderItems, ...(includedItem && [includedItem])]; + }, [] as OrderItem[]); +} + +function resolveShopperOrder( + data: Order[], + included: NonNullable< + ResourcePage["included"] + >, +): { raw: Order; items: OrderItem[] }[] { + // Create a map of included items by their id + const itemMap = included.items.reduce( + (acc, item) => { + return { ...acc, [item.id]: item }; + }, + {} as Record, + ); + + // Map the items in the data array to their corresponding included items + return data.map((order) => { + const orderItems = order.relationships?.items?.data + ? resolveOrderItemsFromRelationship( + order.relationships.items.data, + itemMap, + ) + : []; + + return { + raw: order, + items: orderItems, + }; + }); +} diff --git a/examples/klevu/src/app/(store)/account/summary/YourInfoForm.tsx b/examples/klevu/src/app/(store)/account/summary/YourInfoForm.tsx new file mode 100644 index 00000000..9fb96377 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/summary/YourInfoForm.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { updateAccount } from "./actions"; +import { Label } from "../../../../components/label/Label"; +import { Input } from "../../../../components/input/Input"; +import { FormStatusButton } from "../../../../components/button/FormStatusButton"; +import { useState } from "react"; + +export function YourInfoForm({ + accountId, + defaultValues, +}: { + accountId: string; + defaultValues?: { name?: string; email?: string }; +}) { + const [error, setError] = useState(undefined); + + async function updateAccountAction(formData: FormData) { + const result = await updateAccount(formData); + + if (result && "error" in result) { + setError(result.error); + } + } + + return ( +
    +
    + Your information +

    + + +

    +

    + + +

    + {error && {error}} +
    +
    + Save changes +
    + +
    + ); +} diff --git a/examples/klevu/src/app/(store)/account/summary/actions.ts b/examples/klevu/src/app/(store)/account/summary/actions.ts new file mode 100644 index 00000000..5431608d --- /dev/null +++ b/examples/klevu/src/app/(store)/account/summary/actions.ts @@ -0,0 +1,220 @@ +"use server"; + +import { z } from "zod"; +import { cookies } from "next/headers"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { revalidatePath } from "next/cache"; +import { getServerSideCredentialsClient } from "../../../../lib/epcc-server-side-credentials-client"; +import { getErrorMessage } from "../../../../lib/get-error-message"; + +const updateAccountSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +/** + * TODO request not working for implicit token + EP account management token + * @param formData + */ +export async function updateAccount(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = updateAccountSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid account submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const selectedAccount = getSelectedAccount(accountMemberCreds); + + const { name, id } = validatedFormData.data; + + const body = { + data: { + type: "account", + name, + legal_name: name, + }, + }; + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/accounts/${id}`, + "PUT", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + revalidatePath("/accounts/summary"); + } catch (error) { + console.error(getErrorMessage(error)); + return { + error: getErrorMessage(error), + }; + } + + return; +} + +const updateUserAuthenticationPasswordProfileSchema = z.object({ + username: z.string(), + currentPassword: z.string().optional(), + newPassword: z.string().optional(), +}); + +const PASSWORD_PROFILE_ID = process.env.NEXT_PUBLIC_PASSWORD_PROFILE_ID!; +const AUTHENTICATION_REALM_ID = + process.env.NEXT_PUBLIC_AUTHENTICATION_REALM_ID!; + +export async function updateUserAuthenticationPasswordProfile( + formData: FormData, +) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = + updateUserAuthenticationPasswordProfileSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const { username, newPassword, currentPassword } = validatedFormData.data; + + // Re auth the user to check the current password is correct + const reAuthResult = await client.AccountMembers.GenerateAccountToken({ + type: "account_management_authentication_token", + authentication_mechanism: "password", + password_profile_id: PASSWORD_PROFILE_ID, + username, + password: currentPassword, + }); + + const reAuthedSelectedAccount = reAuthResult.data.find( + (entry) => entry.account_id === accountMemberCreds.selected, + ); + + if (!reAuthedSelectedAccount) { + throw new Error("Error re-authenticating user"); + } + + const credsClient = getServerSideCredentialsClient(); + const userAuthenticationPasswordProfileInfoResult = + await credsClient.UserAuthenticationPasswordProfile.All( + AUTHENTICATION_REALM_ID, + accountMemberCreds.accountMemberId, + ); + + const userAuthenticationPasswordProfileInfo = + userAuthenticationPasswordProfileInfoResult.data.find( + (entry) => entry.password_profile_id === PASSWORD_PROFILE_ID, + ); + + if (!userAuthenticationPasswordProfileInfo) { + throw new Error( + "User authentication password profile info not found for password profile", + ); + } + + const body = { + data: { + type: "user_authentication_password_profile_info", + id: userAuthenticationPasswordProfileInfo.id, + password_profile_id: PASSWORD_PROFILE_ID, + ...(username && { username }), + ...(newPassword && { password: newPassword }), + }, + }; + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/authentication-realms/${AUTHENTICATION_REALM_ID}/user-authentication-info/${accountMemberCreds.accountMemberId}/user-authentication-password-profile-info/${userAuthenticationPasswordProfileInfo.id}`, + "PUT", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": + reAuthedSelectedAccount.token, + }, + ); + + revalidatePath("/accounts"); + } catch (error) { + console.error(error); + throw new Error("Error updating account"); + } +} + +// async function getOneTimePasswordToken( +// client: ElasticPath, +// username: string, +// ): Promise { +// const response = await client.OneTimePasswordTokenRequest.Create( +// AUTHENTICATION_REALM_ID, +// PASSWORD_PROFILE_ID, +// { +// type: "one_time_password_token_request", +// username, +// purpose: "reset_password", +// }, +// ); +// +// const result2 = await client.request.send( +// `/authentication-realms/${AUTHENTICATION_REALM_ID}/password-profiles/${PASSWORD_PROFILE_ID}/one-time-password-token-request`, +// "POST", +// { +// data: { +// type: "one_time_password_token_request", +// username, +// purpose: "reset_password", +// }, +// }, +// undefined, +// client, +// false, +// "v2", +// ); +// +// return response; +// } diff --git a/examples/klevu/src/app/(store)/account/summary/page.tsx b/examples/klevu/src/app/(store)/account/summary/page.tsx new file mode 100644 index 00000000..89825480 --- /dev/null +++ b/examples/klevu/src/app/(store)/account/summary/page.tsx @@ -0,0 +1,113 @@ +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { redirect } from "next/navigation"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { Label } from "../../../../components/label/Label"; +import { Input } from "../../../../components/input/Input"; +import { FormStatusButton } from "../../../../components/button/FormStatusButton"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { updateUserAuthenticationPasswordProfile } from "./actions"; +import { YourInfoForm } from "./YourInfoForm"; + +export const dynamic = "force-dynamic"; + +export default async function AccountSummary() { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const client = getServerSideImplicitClient(); + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const account: Awaited> = + await client.request.send( + `/accounts/${selectedAccount.account_id}`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + const accountMember: Awaited> = + await client.request.send( + `/account-members/${accountMemberCookie.accountMemberId}`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + return ( +
    +
    +

    Your info

    + +
    +
    +

    Change Password

    +
    +
    + Password information +

    + + +

    +

    + + +

    +

    + + +

    +
    + +
    + + Save changes + +
    +
    +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/cart/CartItem.tsx b/examples/klevu/src/app/(store)/cart/CartItem.tsx new file mode 100644 index 00000000..36b2cde6 --- /dev/null +++ b/examples/klevu/src/app/(store)/cart/CartItem.tsx @@ -0,0 +1,61 @@ +"use client"; +import { useCartRemoveItem } from "@elasticpath/react-shopper-hooks"; +import { ProductThumbnail } from "../account/orders/[orderId]/ProductThumbnail"; +import { NumberInput } from "../../../components/number-input/NumberInput"; +import Link from "next/link"; +import { CartItem as CartItemType } from "@elasticpath/js-sdk"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export type CartItemProps = { + item: CartItemType; +}; + +export function CartItem({ item }: CartItemProps) { + const { mutate, isPending } = useCartRemoveItem(); + + return ( +
    +
    + +
    +
    +
    +
    + + {item.name} + + + Quantity: {item.quantity} + +
    +
    + + {item.meta.display_price.with_tax.value.formatted} + + {item.meta.display_price.without_discount?.value.amount && + item.meta.display_price.without_discount?.value.amount !== + item.meta.display_price.with_tax.value.amount && ( + + {item.meta.display_price.without_discount?.value.formatted} + + )} +
    +
    +
    + + {isPending ? ( + + ) : ( + + )} +
    +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/cart/CartItemWide.tsx b/examples/klevu/src/app/(store)/cart/CartItemWide.tsx new file mode 100644 index 00000000..b7421e3c --- /dev/null +++ b/examples/klevu/src/app/(store)/cart/CartItemWide.tsx @@ -0,0 +1,61 @@ +"use client"; +import { useCartRemoveItem } from "@elasticpath/react-shopper-hooks"; +import { ProductThumbnail } from "../account/orders/[orderId]/ProductThumbnail"; +import Link from "next/link"; +import { NumberInput } from "../../../components/number-input/NumberInput"; +import { CartItemProps } from "./CartItem"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export function CartItemWide({ item }: CartItemProps) { + const { mutate, isPending } = useCartRemoveItem(); + + return ( +
    + {/* Thumbnail */} +
    + +
    + {/* Details */} +
    +
    +
    + + + {item.name} + + + + Quantity: {item.quantity} + +
    +
    + + {isPending ? ( + + ) : ( + + )} +
    +
    +
    +
    + + {item.meta.display_price.with_tax.value.formatted} + + {item.meta.display_price.without_discount?.value.amount && + item.meta.display_price.without_discount?.value.amount !== + item.meta.display_price.with_tax.value.amount && ( + + {item.meta.display_price.without_discount?.value.formatted} + + )} +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/cart/CartSidebar.tsx b/examples/klevu/src/app/(store)/cart/CartSidebar.tsx new file mode 100644 index 00000000..2b80fcad --- /dev/null +++ b/examples/klevu/src/app/(store)/cart/CartSidebar.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useCart } from "@elasticpath/react-shopper-hooks"; +import { Separator } from "../../../components/separator/Separator"; +import { CartDiscounts } from "../../../components/cart/CartDiscounts"; +import * as React from "react"; +import { + ItemSidebarPromotions, + ItemSidebarSumTotal, + ItemSidebarTotals, + ItemSidebarTotalsDiscount, + ItemSidebarTotalsSubTotal, + ItemSidebarTotalsTax, +} from "../../../components/checkout-sidebar/ItemSidebar"; + +export function CartSidebar() { + const { data } = useCart(); + + const state = data?.state; + + if (!state) { + return null; + } + + const { meta } = state; + + return ( +
    + + + + {/* Totals */} + + +
    + Shipping + Calculated at checkout +
    + + +
    + + {/* Sum Total */} + +
    + ); +} diff --git a/examples/klevu/src/app/(store)/cart/CartView.tsx b/examples/klevu/src/app/(store)/cart/CartView.tsx new file mode 100644 index 00000000..0e8b7159 --- /dev/null +++ b/examples/klevu/src/app/(store)/cart/CartView.tsx @@ -0,0 +1,55 @@ +"use client"; +import { YourBag } from "./YourBag"; +import { CartSidebar } from "./CartSidebar"; +import { Button } from "../../../components/button/Button"; +import Link from "next/link"; +import { LockClosedIcon } from "@heroicons/react/24/solid"; +import { useCart } from "@elasticpath/react-shopper-hooks"; + +export function CartView() { + const { data } = useCart(); + + const state = data?.state; + + return ( + <> + {state?.items.length && state.items.length > 0 ? ( +
    + {/* Main Content */} +
    +
    +

    Your Bag

    + {/* Cart Items */} + +
    +
    + {/* Sidebar */} +
    + + +
    +
    + ) : ( + <> + {/* Empty Cart */} +
    +

    + Empty Cart +

    +

    Your cart is empty

    +
    + +
    +
    + + )} + + ); +} diff --git a/examples/klevu/src/app/(store)/cart/YourBag.tsx b/examples/klevu/src/app/(store)/cart/YourBag.tsx new file mode 100644 index 00000000..bda5c894 --- /dev/null +++ b/examples/klevu/src/app/(store)/cart/YourBag.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { CartItemWide } from "./CartItemWide"; +import { useCart } from "@elasticpath/react-shopper-hooks"; + +export function YourBag() { + const { data } = useCart(); + + const state = data?.state; + + return ( +
      + {state?.items.map((item) => { + return ( +
    • + +
    • + ); + })} +
    + ); +} diff --git a/examples/klevu/src/app/(store)/cart/page.tsx b/examples/klevu/src/app/(store)/cart/page.tsx new file mode 100644 index 00000000..de5e40bc --- /dev/null +++ b/examples/klevu/src/app/(store)/cart/page.tsx @@ -0,0 +1,5 @@ +import { CartView } from "./CartView"; + +export default async function CartPage() { + return ; +} diff --git a/examples/klevu/src/app/(store)/faq/page.tsx b/examples/klevu/src/app/(store)/faq/page.tsx new file mode 100644 index 00000000..786734bc --- /dev/null +++ b/examples/klevu/src/app/(store)/faq/page.tsx @@ -0,0 +1,5 @@ +import Blurb from "../../../components/shared/blurb"; + +export default function FAQ() { + return ; +} diff --git a/examples/klevu/src/app/(store)/layout.tsx b/examples/klevu/src/app/(store)/layout.tsx new file mode 100644 index 00000000..26377c29 --- /dev/null +++ b/examples/klevu/src/app/(store)/layout.tsx @@ -0,0 +1,64 @@ +import { ReactNode, Suspense } from "react"; +import { Inter } from "next/font/google"; +import { getStoreInitialState } from "../../lib/get-store-initial-state"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { Providers } from "../providers"; +import Header from "../../components/header/Header"; +import { Toaster } from "../../components/toast/toaster"; +import Footer from "../../components/footer/Footer"; + +const { SITE_NAME } = process.env; +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +export const metadata = { + metadataBase: new URL(baseUrl), + title: { + default: SITE_NAME!, + template: `%s | ${SITE_NAME}`, + }, + robots: { + follow: true, + index: true, + }, +}; + +/** + * Used to revalidate until the js-sdk supports passing of fetch options. + * At that point we can be more intelligent about revalidation. + */ +export const revalidate = 300; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export default async function StoreLayout({ + children, +}: { + children: ReactNode; +}) { + const client = getServerSideImplicitClient(); + const initialState = await getStoreInitialState(client); + + return ( + + + {/* headless ui needs this div - https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1745272229 */} +
    + +
    + + +
    {children}
    +
    +
    + +
    + + + ); +} diff --git a/examples/klevu/src/app/(store)/not-found.tsx b/examples/klevu/src/app/(store)/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/klevu/src/app/(store)/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
    + + 404 - The page could not be found. + + + Back to home + +
    + ); +} diff --git a/examples/klevu/src/app/(store)/page.tsx b/examples/klevu/src/app/(store)/page.tsx new file mode 100644 index 00000000..6f87cd28 --- /dev/null +++ b/examples/klevu/src/app/(store)/page.tsx @@ -0,0 +1,38 @@ +import PromotionBanner from "../../components/promotion-banner/PromotionBanner"; +import FeaturedProducts from "../../components/featured-products/FeaturedProducts"; +import { Suspense } from "react"; + +export default async function Home() { + const promotion = { + title: "Your Elastic Path storefront", + description: + "This marks the beginning, embark on the journey of crafting something truly extraordinary, uniquely yours.", + }; + + return ( +
    + +
    +
    +
    + + + +
    +
    +
    +
    + ); +} diff --git a/examples/klevu/src/app/(store)/products/[productId]/page.tsx b/examples/klevu/src/app/(store)/products/[productId]/page.tsx new file mode 100644 index 00000000..5de880de --- /dev/null +++ b/examples/klevu/src/app/(store)/products/[productId]/page.tsx @@ -0,0 +1,54 @@ +import { Metadata } from "next"; +import { ProductDetailsComponent, ProductProvider } from "./product-display"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { getProductById } from "../../../../services/products"; +import { notFound } from "next/navigation"; +import { parseProductResponse } from "@elasticpath/react-shopper-hooks"; +import React from "react"; + + import { RecommendedProducts } from "../../../../components/recommendations/RecommendationProducts"; + +export const dynamic = "force-dynamic"; + +type Props = { + params: { productId: string }; +}; + +export async function generateMetadata({ + params: { productId }, +}: Props): Promise { + const client = getServerSideImplicitClient(); + const product = await getProductById(productId, client); + + if (!product) { + notFound(); + } + + return { + title: product.data.attributes.name, + description: product.data.attributes.description, + }; +} + +export default async function ProductPage({ params }: Props) { + const client = getServerSideImplicitClient(); + const product = await getProductById(params.productId, client); + + if (!product) { + notFound(); + } + + const shopperProduct = await parseProductResponse(product, client); + + return ( +
    + + + + +
    + ); +} diff --git a/examples/klevu/src/app/(store)/products/[productId]/product-display.tsx b/examples/klevu/src/app/(store)/products/[productId]/product-display.tsx new file mode 100644 index 00000000..98ab9c15 --- /dev/null +++ b/examples/klevu/src/app/(store)/products/[productId]/product-display.tsx @@ -0,0 +1,49 @@ +"use client"; +import React, { ReactElement, ReactNode, useState } from "react"; +import { ShopperProduct } from "@elasticpath/react-shopper-hooks"; +import { VariationProductDetail } from "../../../../components/product/variations/VariationProduct"; +import BundleProductDetail from "../../../../components/product/bundles/BundleProduct"; +import { ProductContext } from "../../../../lib/product-context"; +import SimpleProductDetail from "../../../../components/product/SimpleProduct"; + +export function ProductProvider({ + children, +}: { + children: ReactNode; +}): ReactElement { + const [isChangingSku, setIsChangingSku] = useState(false); + + return ( + + {children} + + ); +} + +export function resolveProductDetailComponent( + product: ShopperProduct, +): JSX.Element { + switch (product.kind) { + case "base-product": + return ; + case "child-product": + return ; + case "simple-product": + return ; + case "bundle-product": + return ; + } +} + +export function ProductDetailsComponent({ + product, +}: { + product: ShopperProduct; +}) { + return resolveProductDetailComponent(product); +} diff --git a/examples/klevu/src/app/(store)/search/[[...node]]/layout.tsx b/examples/klevu/src/app/(store)/search/[[...node]]/layout.tsx new file mode 100644 index 00000000..937b6e50 --- /dev/null +++ b/examples/klevu/src/app/(store)/search/[[...node]]/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from "react"; +import Breadcrumb from "../../../../components/breadcrumb"; + +export default function SearchLayout({ children }: { children: ReactNode }) { + return ( +
    + + {children} +
    + ); +} diff --git a/examples/klevu/src/app/(store)/search/[[...node]]/page.tsx b/examples/klevu/src/app/(store)/search/[[...node]]/page.tsx new file mode 100644 index 00000000..6e4b06bd --- /dev/null +++ b/examples/klevu/src/app/(store)/search/[[...node]]/page.tsx @@ -0,0 +1,11 @@ +import SearchResults from "../../../../components/search/SearchResults"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Search", + description: "Search for products", +}; + +export default async function SearchPage() { + return ; +} diff --git a/examples/klevu/src/app/(store)/shipping/page.tsx b/examples/klevu/src/app/(store)/shipping/page.tsx new file mode 100644 index 00000000..d5ee20b4 --- /dev/null +++ b/examples/klevu/src/app/(store)/shipping/page.tsx @@ -0,0 +1,5 @@ +import Blurb from "../../../components/shared/blurb"; + +export default function Shipping() { + return ; +} diff --git a/examples/klevu/src/app/(store)/terms/page.tsx b/examples/klevu/src/app/(store)/terms/page.tsx new file mode 100644 index 00000000..3b651abc --- /dev/null +++ b/examples/klevu/src/app/(store)/terms/page.tsx @@ -0,0 +1,5 @@ +import Blurb from "../../../components/shared/blurb"; + +export default function Terms() { + return ; +} diff --git a/examples/klevu/src/app/configuration-error/page.tsx b/examples/klevu/src/app/configuration-error/page.tsx new file mode 100644 index 00000000..5b9a5dba --- /dev/null +++ b/examples/klevu/src/app/configuration-error/page.tsx @@ -0,0 +1,69 @@ +import Link from "next/link"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Configuration Error", + description: "Configuration error page", +}; + +type Props = { + searchParams: { [key: string]: string | string[] | undefined }; +}; + +export default function ConfigurationErrorPage({ searchParams }: Props) { + const { + "missing-env-variable": missingEnvVariables, + authentication, + from, + } = searchParams; + + const issues: { [key: string]: string | string[] } = { + ...(missingEnvVariables && { missingEnvVariables }), + ...(authentication && { authentication }), + }; + const fromProcessed = Array.isArray(from) ? from[0] : from; + + return ( +
    + + There is a problem with the stores setup + + + Refresh + + + + + + + + + + {issues && + Object.keys(issues).map((key) => { + const issue = issues[key]; + return ( + + + + + ); + })} + +
    IssueDetails
    {key} +
      + {(Array.isArray(issue) ? issue : [issue]).map( + (message) => ( +
    • + {decodeURIComponent(message)} +
    • + ), + )} +
    +
    +
    + ); +} diff --git a/examples/klevu/src/app/error.tsx b/examples/klevu/src/app/error.tsx new file mode 100644 index 00000000..f4724026 --- /dev/null +++ b/examples/klevu/src/app/error.tsx @@ -0,0 +1,31 @@ +"use client"; +import Link from "next/link"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + +
    + + {error.digest} - Internal server error. + + + Back to home + + +
    + + + ); +} diff --git a/examples/klevu/src/app/layout.tsx b/examples/klevu/src/app/layout.tsx new file mode 100644 index 00000000..d87bb7de --- /dev/null +++ b/examples/klevu/src/app/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import "../styles/globals.css"; + +export default async function RootLayout({ + children, +}: { + children: ReactNode; +}) { + return <>{children}; +} diff --git a/examples/klevu/src/app/not-found.tsx b/examples/klevu/src/app/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/klevu/src/app/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
    + + 404 - The page could not be found. + + + Back to home + +
    + ); +} diff --git a/examples/klevu/src/app/providers.tsx b/examples/klevu/src/app/providers.tsx new file mode 100644 index 00000000..acb2a6ba --- /dev/null +++ b/examples/klevu/src/app/providers.tsx @@ -0,0 +1,44 @@ +"use client"; +import { ReactNode } from "react"; +import { + AccountProvider, + StoreProvider, + ElasticPathProvider, + InitialState, +} from "@elasticpath/react-shopper-hooks"; +import { QueryClient } from "@tanstack/react-query"; +import { getEpccImplicitClient } from "../lib/epcc-implicit-client"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../lib/cookie-constants"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 60 * 24, + retry: 1, + }, + }, +}); + +export function Providers({ + children, + initialState, +}: { + children: ReactNode; + initialState: InitialState; +}) { + const client = getEpccImplicitClient(); + + return ( + + + + {children} + + + + ); +} diff --git a/examples/klevu/src/components/Checkbox.tsx b/examples/klevu/src/components/Checkbox.tsx new file mode 100644 index 00000000..290cf273 --- /dev/null +++ b/examples/klevu/src/components/Checkbox.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { cn } from "../lib/cn"; +import { CheckIcon } from "@heroicons/react/24/solid"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/examples/klevu/src/components/LoadingDots.tsx b/examples/klevu/src/components/LoadingDots.tsx new file mode 100644 index 00000000..5ac56f3d --- /dev/null +++ b/examples/klevu/src/components/LoadingDots.tsx @@ -0,0 +1,13 @@ +import { cn } from "../lib/cn"; + +const dots = "mx-[1px] inline-block h-1 w-1 animate-blink rounded-md"; + +export function LoadingDots({ className }: { className: string }) { + return ( + + + + + + ); +} diff --git a/examples/klevu/src/components/NoImage.tsx b/examples/klevu/src/components/NoImage.tsx new file mode 100644 index 00000000..bf84f253 --- /dev/null +++ b/examples/klevu/src/components/NoImage.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { EyeSlashIcon } from "@heroicons/react/24/solid"; + +export const NoImage = (): JSX.Element => { + return ( +
    + +
    + ); +}; + +export default NoImage; diff --git a/examples/klevu/src/components/Spinner.tsx b/examples/klevu/src/components/Spinner.tsx new file mode 100644 index 00000000..9bd90a3e --- /dev/null +++ b/examples/klevu/src/components/Spinner.tsx @@ -0,0 +1,30 @@ +interface IProps { + width: string; + height: string; + absolute: boolean; +} + +const Spinner = (props: IProps) => { + return ( + + ); +}; + +export default Spinner; diff --git a/examples/klevu/src/components/accordion/Accordion.tsx b/examples/klevu/src/components/accordion/Accordion.tsx new file mode 100644 index 00000000..6f4f7aef --- /dev/null +++ b/examples/klevu/src/components/accordion/Accordion.tsx @@ -0,0 +1,51 @@ +"use client"; + +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { cn } from "../../lib/cn"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
    {children}
    +
    +)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/examples/klevu/src/components/alert/Alert.tsx b/examples/klevu/src/components/alert/Alert.tsx new file mode 100644 index 00000000..eb8d9cd4 --- /dev/null +++ b/examples/klevu/src/components/alert/Alert.tsx @@ -0,0 +1,59 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +import { forwardRef, HTMLAttributes } from "react"; +import { cn } from "../../lib/cn"; + +const alertVariants = cva( + "relative w-full rounded-lg border border-black/60 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-black", + { + variants: { + variant: { + default: "bg-white text-black", + destructive: + "border-red-600/50 text-red-600 dark:border-red-600 [&>svg]:text-red-600", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = forwardRef< + HTMLDivElement, + HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
    +)); +Alert.displayName = "Alert"; + +const AlertTitle = forwardRef< + HTMLParagraphElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = forwardRef< + HTMLParagraphElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/examples/klevu/src/components/breadcrumb.tsx b/examples/klevu/src/components/breadcrumb.tsx new file mode 100644 index 00000000..950c0130 --- /dev/null +++ b/examples/klevu/src/components/breadcrumb.tsx @@ -0,0 +1,36 @@ +"use client"; +import { createBreadcrumb } from "../lib/create-breadcrumb"; +import Link from "next/link"; +import { useStore } from "@elasticpath/react-shopper-hooks"; +import { buildBreadcrumbLookup } from "../lib/build-breadcrumb-lookup"; +import { usePathname } from "next/navigation"; + +export default function Breadcrumb(): JSX.Element { + const { nav } = useStore(); + const pathname = usePathname(); + const lookup = buildBreadcrumbLookup(nav ?? []); + const nodes = pathname.replace("/search/", "")?.split("/"); + const crumbs = createBreadcrumb(nodes, lookup); + + return ( +
      + {crumbs.length > 1 && + crumbs.map((entry, index, array) => ( +
    1. + {array.length === index + 1 ? ( + {entry.label} + ) : ( + + {entry.label} + + )} + {array.length !== index + 1 && /} +
    2. + ))} +
    + ); +} diff --git a/examples/klevu/src/components/button/Button.tsx b/examples/klevu/src/components/button/Button.tsx new file mode 100644 index 00000000..6d277a4e --- /dev/null +++ b/examples/klevu/src/components/button/Button.tsx @@ -0,0 +1,68 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../../lib/cn"; +import { ButtonHTMLAttributes, forwardRef } from "react"; +import { Slot } from "@radix-ui/react-slot"; + +const buttonVariants = cva( + "inline-flex items-center justify-center hover:opacity-90 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: "bg-black text-white", + secondary: "bg-transparent ring-2 ring-inset ring-black text-black", + ghost: "bg-brand-gray/10 text-black/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "px-8 py-3 text-lg", + medium: "px-6 py-2 text-base", + small: "px-3.5 py-1.5 text-sm", + icon: "w-10", + }, + reversed: { + true: "", + false: "", + }, + }, + compoundVariants: [ + { + variant: "primary", + reversed: true, + className: "bg-white text-black", + }, + { + variant: "secondary", + reversed: true, + className: "ring-white text-white", + }, + ], + defaultVariants: { + variant: "primary", + size: "default", + reversed: false, + }, + }, +); + +export interface ButtonProps + extends ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = forwardRef( + ({ className, variant, asChild = false, size, reversed, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/examples/klevu/src/components/button/FormStatusButton.tsx b/examples/klevu/src/components/button/FormStatusButton.tsx new file mode 100644 index 00000000..2b46a37e --- /dev/null +++ b/examples/klevu/src/components/button/FormStatusButton.tsx @@ -0,0 +1,41 @@ +"use client"; +import { cn } from "../../lib/cn"; +import { forwardRef, ReactNode } from "react"; +import { Button, ButtonProps } from "./Button"; +import LoaderIcon from "./LoaderIcon"; +import { useFormStatus } from "react-dom"; +import * as React from "react"; + +export interface FormStatusButtonProps extends ButtonProps { + status?: "loading" | "success" | "error" | "idle"; + icon?: ReactNode; +} + +const FormStatusButton = forwardRef( + ({ children, icon, status = "idle", className, ...props }, ref) => { + const { pending } = useFormStatus(); + return ( + + ); + }, +); +FormStatusButton.displayName = "FormStatusButton"; + +export { FormStatusButton }; diff --git a/examples/klevu/src/components/button/LoaderIcon.tsx b/examples/klevu/src/components/button/LoaderIcon.tsx new file mode 100644 index 00000000..92aa3952 --- /dev/null +++ b/examples/klevu/src/components/button/LoaderIcon.tsx @@ -0,0 +1,17 @@ +import { forwardRef, Ref, SVGProps } from "react"; + +const LoaderIcon = ( + props: SVGProps, + ref: Ref, +) => ( + + + +); +const ForwardRef = forwardRef(LoaderIcon); +export default ForwardRef; diff --git a/examples/klevu/src/components/button/StatusButton.tsx b/examples/klevu/src/components/button/StatusButton.tsx new file mode 100644 index 00000000..12ee784a --- /dev/null +++ b/examples/klevu/src/components/button/StatusButton.tsx @@ -0,0 +1,47 @@ +import { cn } from "../../lib/cn"; +import { forwardRef } from "react"; +import { Button, ButtonProps } from "./Button"; +import { CheckIcon } from "@heroicons/react/24/outline"; +import { XMarkIcon } from "@heroicons/react/24/solid"; +import LoaderIcon from "./LoaderIcon"; + +export interface StatusButtonProps extends ButtonProps { + status?: "loading" | "success" | "error" | "idle"; +} + +const StatusButton = forwardRef( + ({ children, status = "idle", className, ...props }, ref) => { + const Icon = + status === "loading" + ? LoaderIcon + : status === "success" + ? CheckIcon + : status === "error" + ? XMarkIcon + : null; + + return ( + + ); + }, +); +StatusButton.displayName = "StatusButton"; + +export { StatusButton }; diff --git a/examples/klevu/src/components/button/TextButton.tsx b/examples/klevu/src/components/button/TextButton.tsx new file mode 100644 index 00000000..e48149af --- /dev/null +++ b/examples/klevu/src/components/button/TextButton.tsx @@ -0,0 +1,41 @@ +import { cva, VariantProps } from "class-variance-authority"; +import { ButtonHTMLAttributes, forwardRef } from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "../../lib/cn"; + +const textButtonVariants = cva( + "font-medium text-black flex items-center gap-2 shrink-0", + { + variants: { + size: { + default: "text-lg", + small: "text-base", + }, + }, + defaultVariants: { + size: "default", + }, + }, +); + +export interface TextButtonProps + extends ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const TextButton = forwardRef( + ({ className, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +TextButton.displayName = "TextButton"; + +export { TextButton, textButtonVariants }; diff --git a/examples/klevu/src/components/cart/CartDiscounts.tsx b/examples/klevu/src/components/cart/CartDiscounts.tsx new file mode 100644 index 00000000..d6090d4e --- /dev/null +++ b/examples/klevu/src/components/cart/CartDiscounts.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { forwardRef, Fragment, HTMLAttributes } from "react"; +import { Separator } from "../separator/Separator"; +import { + PromotionCartItem, + useCartRemoveItem, +} from "@elasticpath/react-shopper-hooks"; +import { LoadingDots } from "../LoadingDots"; +import { XMarkIcon } from "@heroicons/react/24/solid"; +import * as React from "react"; +import { cn } from "../../lib/cn"; + +export function CartDiscounts({ + promotions, +}: { + promotions: PromotionCartItem[]; +}) { + const { mutate, isPending } = useCartRemoveItem(); + + return ( + promotions && + promotions.length > 0 && + promotions.map((promotion) => { + return ( + + + + {promotion.name} + + + + ); + }) + ); +} + +export function CartDiscountsReadOnly({ + promotions, +}: { + promotions: PromotionCartItem[]; +}) { + return ( + promotions && + promotions.length > 0 && + promotions.map((promotion) => { + return ( + + + {promotion.name} + + + + ); + }) + ); +} + +const CartDiscountItem = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, children, ...props }, ref) => { + return ( +
    +
    {children}
    +
    + ); +}); +CartDiscountItem.displayName = "CartDiscountItem"; + +const CartDiscountName = forwardRef< + HTMLSpanElement, + HTMLAttributes +>(({ className, children, ...props }, ref) => { + return ( + + {children} + + ); +}); +CartDiscountName.displayName = "CartDiscountName"; diff --git a/examples/klevu/src/components/cart/CartSheet.tsx b/examples/klevu/src/components/cart/CartSheet.tsx new file mode 100644 index 00000000..75f36349 --- /dev/null +++ b/examples/klevu/src/components/cart/CartSheet.tsx @@ -0,0 +1,168 @@ +"use client"; +import { + Sheet, + SheetClose, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "../sheet/Sheet"; +import { Button } from "../button/Button"; +import { LockClosedIcon, XMarkIcon } from "@heroicons/react/24/solid"; +import { CartItem } from "../../app/(store)/cart/CartItem"; +import { useCart, useCartRemoveItem } from "@elasticpath/react-shopper-hooks"; +import { Separator } from "../separator/Separator"; +import { ShoppingBagIcon } from "@heroicons/react/24/outline"; +import { Fragment } from "react"; +import { AddPromotion } from "../checkout-sidebar/AddPromotion"; +import Link from "next/link"; +import { LoadingDots } from "../LoadingDots"; + +export function Cart() { + const { data } = useCart(); + + const state = data?.state; + + const { items, __extended } = state ?? {}; + + const { mutate, isPending } = useCartRemoveItem(); + + const discountedValues = ( + state?.meta?.display_price as + | { discount: { amount: number; formatted: string } } + | undefined + )?.discount; + + return ( + + + + + + +
    + + Your Bag + + + + Close + +
    + {items && items.length > 0 ? ( + <> + {/* Items */} +
    +
      + {items.map((item) => { + return ( + +
    • + +
    • + +
      + ); + })} +
    +
    + {/* Bottom */} + +
    + +
    + {__extended && + __extended.groupedItems.promotion.length > 0 && + __extended.groupedItems.promotion.map((promotion) => { + return ( + + +
    +
    + + {promotion.name} +
    +
    +
    + ); + })} + + {/* Totals */} +
    +
    + Sub Total + + {state?.meta?.display_price?.without_tax?.formatted} + +
    + {discountedValues && discountedValues.amount !== 0 && ( +
    + Discount + + {discountedValues.formatted} + +
    + )} +
    + + + + + + + +
    + + ) : ( +
    + +

    Your bag is empty.

    +
    + )} +
    +
    + ); +} diff --git a/examples/klevu/src/components/checkout-item/CheckoutItem.tsx b/examples/klevu/src/components/checkout-item/CheckoutItem.tsx new file mode 100644 index 00000000..0bb311bf --- /dev/null +++ b/examples/klevu/src/components/checkout-item/CheckoutItem.tsx @@ -0,0 +1,32 @@ +"use client"; +import { ProductThumbnail } from "../../app/(store)/account/orders/[orderId]/ProductThumbnail"; +import Link from "next/link"; +import { CartItem } from "@elasticpath/js-sdk"; + +export function CheckoutItem({ item }: { item: CartItem }) { + return ( +
    +
    + +
    +
    + + {item.name} + + Quantity: {item.quantity} +
    +
    + + {item.meta.display_price.with_tax.value.formatted} + + {item.meta.display_price.without_discount?.value.amount && + item.meta.display_price.without_discount?.value.amount !== + item.meta.display_price.with_tax.value.amount && ( + + {item.meta.display_price.without_discount?.value.formatted} + + )} +
    +
    + ); +} diff --git a/examples/klevu/src/components/checkout-sidebar/AddPromotion.tsx b/examples/klevu/src/components/checkout-sidebar/AddPromotion.tsx new file mode 100644 index 00000000..2b6ad7bb --- /dev/null +++ b/examples/klevu/src/components/checkout-sidebar/AddPromotion.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { TextButton } from "../button/TextButton"; +import { PlusIcon } from "@heroicons/react/24/outline"; +import { Input } from "../input/Input"; +import { Button } from "../button/Button"; +import { applyDiscount } from "./actions"; +import { useQueryClient } from "@tanstack/react-query"; +import { cartQueryKeys, useCart } from "@elasticpath/react-shopper-hooks"; +import { useFormStatus } from "react-dom"; +import { LoadingDots } from "../LoadingDots"; + +export function AddPromotion() { + const [showInput, setShowInput] = useState(false); + const queryClient = useQueryClient(); + const { data } = useCart(); + const [error, setError] = useState(undefined); + + async function clientAction(formData: FormData) { + setError(undefined); + + const result = await applyDiscount(formData); + + setError(result.error); + + data?.cartId && + (await queryClient.invalidateQueries({ + queryKey: cartQueryKeys.detail(data.cartId), + })); + + !result.error && setShowInput(false); + } + + return showInput ? ( +
    +
    + + + + {error &&

    {error}

    } +
    + ) : ( + setShowInput(true)}> + Add discount code + + ); +} + +function ApplyButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} diff --git a/examples/klevu/src/components/checkout-sidebar/ItemSidebar.tsx b/examples/klevu/src/components/checkout-sidebar/ItemSidebar.tsx new file mode 100644 index 00000000..8e1b174f --- /dev/null +++ b/examples/klevu/src/components/checkout-sidebar/ItemSidebar.tsx @@ -0,0 +1,166 @@ +"use client"; +import { Separator } from "../separator/Separator"; +import { AddPromotion } from "./AddPromotion"; +import { CheckoutItem } from "../checkout-item/CheckoutItem"; +import { CartState } from "@elasticpath/react-shopper-hooks"; +import { + Accordion, + AccordionContent, + AccordionTrigger, + AccordionItem, +} from "../accordion/Accordion"; +import { ShoppingBagIcon } from "@heroicons/react/24/outline"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import * as React from "react"; +import { Currency } from "@elasticpath/js-sdk"; +import { formatCurrency } from "../../lib/format-currency"; + +export function ItemSidebarItems({ items }: { items: CartState["items"] }) { + return ( + <> + {items && items.length > 0 && ( + <> + {items?.map((item) => )} + + + )} + + ); +} + +export function ItemSidebarPromotions() { + return ( +
    + +
    + ); +} + +export function ItemSidebarSumTotal({ meta }: { meta: CartState["meta"] }) { + return ( +
    + Total +
    + {meta?.display_price?.with_tax?.currency} + + {meta?.display_price?.with_tax?.formatted} + +
    +
    + ); +} + +export function ItemSidebarTotals({ children }: { children: React.ReactNode }) { + return ( +
    {children}
    + ); +} + +export function ItemSidebarTotalsSubTotal({ + meta, +}: { + meta: CartState["meta"]; +}) { + return ( + + ); +} + +export function ItemSidebarTotalsDiscount({ + meta, +}: { + meta: CartState["meta"]; +}) { + const discountedValues = ( + meta?.display_price as + | { discount: { amount: number; formatted: string } } + | undefined + )?.discount; + + return ( + discountedValues && + discountedValues.amount !== 0 && ( +
    + Discount + + {discountedValues?.formatted ?? ""} + +
    + ) + ); +} + +export function ItemSidebarTotalsTax({ meta }: { meta: CartState["meta"] }) { + return ( + + ); +} + +export function ItemSidebarTotalsItem({ + label, + description, +}: { + label: string; + description: string; +}) { + return ( +
    + {label} + {description} +
    + ); +} + +export function ItemSidebarHideable({ + children, + meta, +}: { + meta: CartState["meta"]; + children: React.ReactNode; +}) { + return ( + <> + + + + + Hide order summary + + + {meta?.display_price?.with_tax?.formatted} + + + {children} + + +
    {children}
    + + ); +} + +export function resolveTotalInclShipping( + shippingAmount: number, + totalAmount: number, + storeCurrency: Currency, +): string | undefined { + return formatCurrency(shippingAmount + totalAmount, storeCurrency); +} diff --git a/examples/klevu/src/components/checkout-sidebar/actions.ts b/examples/klevu/src/components/checkout-sidebar/actions.ts new file mode 100644 index 00000000..e2d283b8 --- /dev/null +++ b/examples/klevu/src/components/checkout-sidebar/actions.ts @@ -0,0 +1,43 @@ +"use server"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { cookies } from "next/headers"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { COOKIE_PREFIX_KEY } from "../../lib/resolve-cart-env"; +import { getErrorMessage } from "../../lib/get-error-message"; + +const applyDiscountSchema = z.object({ + code: z.string(), +}); + +export async function applyDiscount(formData: FormData) { + const client = getServerSideImplicitClient(); + + const cartCookie = cookies().get(`${COOKIE_PREFIX_KEY}_ep_cart`)?.value; + + if (!cartCookie) { + throw new Error("Cart cookie not found"); + } + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = applyDiscountSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + return { + error: "Invalid code", + }; + } + + try { + await client.Cart(cartCookie).AddPromotion(validatedFormData.data.code); + revalidatePath("/cart"); + } catch (error) { + console.error(error); + return { + error: getErrorMessage(error), + }; + } + + return { success: true }; +} diff --git a/examples/klevu/src/components/checkout/form-schema/checkout-form-schema.ts b/examples/klevu/src/components/checkout/form-schema/checkout-form-schema.ts new file mode 100644 index 00000000..5c987167 --- /dev/null +++ b/examples/klevu/src/components/checkout/form-schema/checkout-form-schema.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +/** + * Validating optional text input field https://github.com/colinhacks/zod/issues/310 + */ +const emptyStringToUndefined = z.literal("").transform(() => undefined); + +const guestInformationSchema = z.object({ + email: z.string({ required_error: "Required" }).email("Invalid email"), +}); + +const accountMemberInformationSchema = z.object({ + email: z.string({ required_error: "Required" }).email("Invalid email"), + name: z.string({ required_error: "Required" }), +}); + +const billingAddressSchema = z.object({ + first_name: z + .string({ required_error: "You need to provided a first name." }) + .min(2), + last_name: z + .string({ required_error: "You need to provided a last name." }) + .min(2), + company_name: z.string().min(1).optional().or(emptyStringToUndefined), + line_1: z + .string({ required_error: "You need to provided an address." }) + .min(1), + line_2: z.string().min(1).optional().or(emptyStringToUndefined), + city: z.string().min(1).optional().or(emptyStringToUndefined), + county: z.string().min(1).optional().or(emptyStringToUndefined), + region: z + .string({ required_error: "You need to provided a region." }) + .optional() + .or(emptyStringToUndefined), + postcode: z + .string({ required_error: "You need to provided a postcode." }) + .min(1), + country: z + .string({ required_error: "You need to provided a country." }) + .min(1), +}); + +export const shippingAddressSchema = z + .object({ + phone_number: z + .string() + .regex( + /^(\+?\d{0,4})?\s?-?\s?(\(?\d{3}\)?)\s?-?\s?(\(?\d{3}\)?)\s?-?\s?(\(?\d{4}\)?)?$/, + "Phone number is not valid", + ) + .optional() + .or(emptyStringToUndefined), + instructions: z.string().min(1).optional().or(emptyStringToUndefined), + }) + .merge(billingAddressSchema); + +export const anonymousCheckoutFormSchema = z.object({ + guest: guestInformationSchema, + shippingAddress: shippingAddressSchema, + sameAsShipping: z.boolean().default(true), + billingAddress: billingAddressSchema.optional().or(emptyStringToUndefined), + shippingMethod: z + .union([z.literal("__shipping_standard"), z.literal("__shipping_express")]) + .default("__shipping_standard"), +}); + +export type AnonymousCheckoutForm = z.TypeOf< + typeof anonymousCheckoutFormSchema +>; + +export const accountMemberCheckoutFormSchema = z.object({ + account: accountMemberInformationSchema, + shippingAddress: shippingAddressSchema, + sameAsShipping: z.boolean().default(true), + billingAddress: billingAddressSchema.optional().or(emptyStringToUndefined), + shippingMethod: z + .union([z.literal("__shipping_standard"), z.literal("__shipping_express")]) + .default("__shipping_standard"), +}); + +export type AccountMemberCheckoutForm = z.TypeOf< + typeof accountMemberCheckoutFormSchema +>; + +export const checkoutFormSchema = z.union([ + anonymousCheckoutFormSchema, + accountMemberCheckoutFormSchema, +]); + +export type CheckoutForm = z.TypeOf; diff --git a/examples/klevu/src/components/featured-products/FeaturedProducts.tsx b/examples/klevu/src/components/featured-products/FeaturedProducts.tsx new file mode 100644 index 00000000..909ec1ef --- /dev/null +++ b/examples/klevu/src/components/featured-products/FeaturedProducts.tsx @@ -0,0 +1,86 @@ +"use server"; +import clsx from "clsx"; +import Link from "next/link"; +import { ArrowRightIcon, EyeSlashIcon } from "@heroicons/react/24/outline"; +import Image from "next/image"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { fetchFeaturedProducts } from "./fetchFeaturedProducts"; + +interface IFeaturedProductsProps { + title: string; + linkProps?: { + link: string; + text: string; + }; +} + +export default async function FeaturedProducts({ + title, + linkProps, +}: IFeaturedProductsProps) { + const client = getServerSideImplicitClient(); + const products = await fetchFeaturedProducts(client); + + return ( +
    +
    +

    + {title} +

    + {linkProps && ( + + + {linkProps.text} + + + )} +
    +
      + {products.map((product) => ( + +
    • +
      +
      + {product.main_image?.link.href ? ( + {product.main_image?.file_name!} + ) : ( +
      + +
      + )} +
      +
      +

      + {product.attributes.name} +

      +

      + {product.meta.display_price?.without_tax?.formatted} +

      +
    • + + ))} +
    +
    + ); +} diff --git a/examples/klevu/src/components/featured-products/fetchFeaturedProducts.ts b/examples/klevu/src/components/featured-products/fetchFeaturedProducts.ts new file mode 100644 index 00000000..a5b9f7bf --- /dev/null +++ b/examples/klevu/src/components/featured-products/fetchFeaturedProducts.ts @@ -0,0 +1,19 @@ +import { getProducts } from "../../services/products"; +import { ElasticPath } from "@elasticpath/js-sdk"; +import { ProductResponseWithImage } from "../../lib/types/product-types"; +import { connectProductsWithMainImages } from "../../lib/connect-products-with-main-images"; + +// Fetching the first 4 products of in the catalog to display in the featured-products component +export const fetchFeaturedProducts = async ( + client: ElasticPath, +): Promise => { + const { data: productsResponse, included: productsIncluded } = + await getProducts(client); + + return productsIncluded?.main_images + ? connectProductsWithMainImages( + productsResponse.slice(0, 4), // Only need the first 4 products to feature + productsIncluded?.main_images, + ) + : productsResponse; +}; diff --git a/examples/klevu/src/components/footer/Footer.tsx b/examples/klevu/src/components/footer/Footer.tsx new file mode 100644 index 00000000..46759f53 --- /dev/null +++ b/examples/klevu/src/components/footer/Footer.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; +import { PhoneIcon, InformationCircleIcon } from "@heroicons/react/24/solid"; +import { GitHubIcon } from "../icons/github-icon"; +import EpLogo from "../icons/ep-logo"; + +const Footer = (): JSX.Element => ( +
    +
    +
    +
    + +
    +
    + + Home + + + Shipping + + + FAQ + +
    +
    + + About + + + Terms + +
    +
    +
    + + {" "} + + + + {" "} + + + + + +
    +
    +
    +
    +); + +export default Footer; diff --git a/examples/klevu/src/components/form/Form.tsx b/examples/klevu/src/components/form/Form.tsx new file mode 100644 index 00000000..4f760fa5 --- /dev/null +++ b/examples/klevu/src/components/form/Form.tsx @@ -0,0 +1,183 @@ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; +import { cn } from "../../lib/cn"; +import { Label } from "../label/Label"; +import { + ComponentPropsWithoutRef, + createContext, + ElementRef, + forwardRef, + HTMLAttributes, + useContext, + useId, +} from "react"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = useContext(FormFieldContext); + const itemContext = useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = createContext( + {} as FormItemContextValue, +); + +const FormItem = forwardRef>( + ({ className, ...props }, ref) => { + const id = useId(); + + return ( + +
    + + ); + }, +); +FormItem.displayName = "FormItem"; + +const FormLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +
    +
    + ); +}; + +export default ProductVariationColor; diff --git a/examples/klevu/src/components/product/variations/ProductVariationStandard.tsx b/examples/klevu/src/components/product/variations/ProductVariationStandard.tsx new file mode 100644 index 00000000..a326191c --- /dev/null +++ b/examples/klevu/src/components/product/variations/ProductVariationStandard.tsx @@ -0,0 +1,55 @@ +import clsx from "clsx"; +import type { useVariationProduct } from "@elasticpath/react-shopper-hooks"; + +interface ProductVariationOption { + id: string; + description: string; + name: string; +} + +export type UpdateOptionHandler = ( + variationId: string, +) => (optionId: string) => void; + +interface IProductVariation { + variation: { + id: string; + name: string; + options: ProductVariationOption[]; + }; + updateOptionHandler: ReturnType< + typeof useVariationProduct + >["updateSelectedOptions"]; + selectedOptionId?: string; +} + +const ProductVariationStandard = ({ + variation, + selectedOptionId, + updateOptionHandler, +}: IProductVariation): JSX.Element => { + return ( +
    +

    {variation.name}

    +
    + {variation.options.map((o) => ( + + ))} +
    +
    + ); +}; + +export default ProductVariationStandard; diff --git a/examples/klevu/src/components/product/variations/ProductVariations.tsx b/examples/klevu/src/components/product/variations/ProductVariations.tsx new file mode 100644 index 00000000..4ec4e1e2 --- /dev/null +++ b/examples/klevu/src/components/product/variations/ProductVariations.tsx @@ -0,0 +1,108 @@ +import type { CatalogsProductVariation } from "@elasticpath/js-sdk"; +import { useRouter } from "next/navigation"; +import { useContext } from "react"; +import { useEffect } from "react"; +import { OptionDict } from "../../../lib/types/product-types"; +import { + allVariationsHaveSelectedOption, + getSkuIdFromOptions, +} from "../../../lib/product-helper"; +import ProductVariationStandard from "./ProductVariationStandard"; +import ProductVariationColor from "./ProductVariationColor"; +import { useVariationProduct } from "@elasticpath/react-shopper-hooks"; +import { ProductContext } from "../../../lib/product-context"; + +const getSelectedOption = ( + variationId: string, + optionLookupObj: OptionDict, +): string => { + return optionLookupObj[variationId]; +}; + +const ProductVariations = (): JSX.Element => { + const { + variations, + variationsMatrix, + product, + selectedOptions, + updateSelectedOptions, + } = useVariationProduct(); + + const currentProductId = product.response.id; + + const context = useContext(ProductContext); + + const router = useRouter(); + + useEffect(() => { + const selectedSkuId = getSkuIdFromOptions( + Object.values(selectedOptions), + variationsMatrix, + ); + + if ( + !context?.isChangingSku && + selectedSkuId && + selectedSkuId !== currentProductId && + allVariationsHaveSelectedOption(selectedOptions, variations) + ) { + context?.setIsChangingSku(true); + router.replace(`/products/${selectedSkuId}`, { scroll: false }); + context?.setIsChangingSku(false); + } + }, [ + selectedOptions, + context, + currentProductId, + router, + variations, + variationsMatrix, + ]); + + return ( +
    + {variations.map((v) => + resolveVariationComponentByName( + v, + updateSelectedOptions, + getSelectedOption(v.id, selectedOptions), + ), + )} +
    + ); +}; + +function resolveVariationComponentByName( + v: CatalogsProductVariation, + updateOptionHandler: ReturnType< + typeof useVariationProduct + >["updateSelectedOptions"], + selectedOptionId?: string, +): JSX.Element { + switch (v.name.toLowerCase()) { + case "color": + return ( + + ); + default: + return ( + + ); + } +} + +export default ProductVariations; diff --git a/examples/klevu/src/components/product/variations/VariationProduct.tsx b/examples/klevu/src/components/product/variations/VariationProduct.tsx new file mode 100644 index 00000000..77fb2bc4 --- /dev/null +++ b/examples/klevu/src/components/product/variations/VariationProduct.tsx @@ -0,0 +1,60 @@ +"use client"; +import { + useCartAddProduct, + useVariationProduct, + VariationProduct, + VariationProductProvider, +} from "@elasticpath/react-shopper-hooks"; +import ProductVariations from "./ProductVariations"; +import ProductCarousel from "../carousel/ProductCarousel"; +import ProductSummary from "../ProductSummary"; +import ProductDetails from "../ProductDetails"; +import ProductExtensions from "../ProductExtensions"; +import { StatusButton } from "../../button/StatusButton"; + +export const VariationProductDetail = ({ + variationProduct, +}: { + variationProduct: VariationProduct; +}): JSX.Element => { + return ( + + + + ); +}; + +export function VariationProductContainer(): JSX.Element { + const { product } = useVariationProduct(); + const { mutate, isPending } = useCartAddProduct(); + + const { response, main_image, otherImages } = product; + const { extensions } = response.attributes; + return ( +
    +
    +
    + {main_image && ( + + )} +
    +
    +
    + + + + {extensions && } + mutate({ productId: response.id, quantity: 1 })} + status={isPending ? "loading" : "idle"} + > + ADD TO CART + +
    +
    +
    +
    + ); +} diff --git a/examples/klevu/src/components/promotion-banner/PromotionBanner.tsx b/examples/klevu/src/components/promotion-banner/PromotionBanner.tsx new file mode 100644 index 00000000..4430aa13 --- /dev/null +++ b/examples/klevu/src/components/promotion-banner/PromotionBanner.tsx @@ -0,0 +1,62 @@ +"use client"; +import { useRouter } from "next/navigation"; +import clsx from "clsx"; + +export interface IPromotion { + title?: string; + description?: string; + imageHref?: string; +} + +interface IPromotionBanner { + linkProps?: { + link: string; + text: string; + }; + alignment?: "center" | "left" | "right"; + promotion: IPromotion; +} + +const PromotionBanner = (props: IPromotionBanner): JSX.Element => { + const router = useRouter(); + const { linkProps, promotion } = props; + + const { title, description } = promotion; + + return ( + <> + {promotion && ( +
    +
    + {title && ( +

    + {title} +

    + )} + {description && ( +

    + {description} +

    + )} + {linkProps && ( + + )} +
    +
    + )} + + ); +}; + +export default PromotionBanner; diff --git a/examples/klevu/src/components/radio-group/RadioGroup.tsx b/examples/klevu/src/components/radio-group/RadioGroup.tsx new file mode 100644 index 00000000..b9f3ba24 --- /dev/null +++ b/examples/klevu/src/components/radio-group/RadioGroup.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { cn } from "../../lib/cn"; + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + + + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/examples/klevu/src/components/recommendations/RecommendationProducts.tsx b/examples/klevu/src/components/recommendations/RecommendationProducts.tsx new file mode 100644 index 00000000..f57aa99e --- /dev/null +++ b/examples/klevu/src/components/recommendations/RecommendationProducts.tsx @@ -0,0 +1,51 @@ +"use client"; +import { ShopperProduct } from "@elasticpath/react-shopper-hooks"; +import { useEffect, useState } from "react"; +import { KlevuRecord } from "@klevu/core"; +import Hits from "../search/Hits"; +import HitPlaceholder from "../search/HitPlaceholder"; +import { fetchFeatureProducts, fetchSimilarProducts } from "../../lib/klevu"; + +export function RecommendedProducts({ + product, +}: { + product: ShopperProduct; +}) { + + const [products, setProducts] = useState(); + + const doFetch = async () => { + const productId = product.response.id; + + const similarProductsResponse = await fetchSimilarProducts(productId); + const similarProducts = similarProductsResponse?.[0]?.records; + + if (similarProducts && similarProducts.length > 0) { + setProducts(similarProducts); + } else { + const resp = await fetchFeatureProducts(); + setProducts(resp.records); + } + } + + useEffect(() => { + doFetch(); + }, []); + + return ( + <> +

    You might also like

    + {products && } + {!products && +
    +
    + +
    +
    + } + + + ); +} diff --git a/examples/klevu/src/components/search/Hit.tsx b/examples/klevu/src/components/search/Hit.tsx new file mode 100644 index 00000000..27c15914 --- /dev/null +++ b/examples/klevu/src/components/search/Hit.tsx @@ -0,0 +1,90 @@ +import Link from "next/link"; +import Price from "../product/Price"; +import StrikePrice from "../product/StrikePrice"; +import Image from "next/image"; +import { EyeSlashIcon } from "@heroicons/react/24/solid"; +import { KlevuRecord } from "@klevu/core"; + +function formatCurrency(price: string, currency: string) { + return Intl.NumberFormat(undefined, { + style: "currency", + currency + }).format(parseFloat(price)); +} + +export default function HitComponent({ + hit, + clickEvent +}: { + hit: KlevuRecord; + clickEvent?: (params: { productId: string}) => void +}): JSX.Element { + const { + image: ep_main_image_url, + price, + salePrice, + id, + name, + shortDesc: description, + currency + } = hit; + + return ( + <> + +
    clickEvent ? clickEvent({ productId: id}) : null } + > +
    + {ep_main_image_url ? ( + {name} + ) : ( +
    + +
    + )} +
    +
    +
    + +

    {name}

    + + + {description} + +
    +
    + {price && ( +
    + + {price && (price !== salePrice) && ( + + )} +
    + )} +
    +
    +
    + + + ); +} diff --git a/examples/klevu/src/components/search/HitPlaceholder.tsx b/examples/klevu/src/components/search/HitPlaceholder.tsx new file mode 100644 index 00000000..9ac2a907 --- /dev/null +++ b/examples/klevu/src/components/search/HitPlaceholder.tsx @@ -0,0 +1,29 @@ +export default function HitPlaceholder(): JSX.Element { + return ( + <> +
    +
    +
    +
    +
    + + Loading... +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + ); +} diff --git a/examples/klevu/src/components/search/Hits.tsx b/examples/klevu/src/components/search/Hits.tsx new file mode 100644 index 00000000..533e9f24 --- /dev/null +++ b/examples/klevu/src/components/search/Hits.tsx @@ -0,0 +1,28 @@ +import NoResults from "./NoResults"; +import HitComponent from "./Hit"; +import { KlevuRecord } from "@klevu/core"; + +type HitsProps = { + data: KlevuRecord[]; + clickEvent?: (params: { productId: string}) => void +} + +export default function Hits({ data, clickEvent }: HitsProps): JSX.Element { + if (data.length) { + return ( +
    + {data.map((hit) => { + return ( +
    + +
    + ); + })} +
    + ); + } + return ; +} diff --git a/examples/klevu/src/components/search/MobileFilters.tsx b/examples/klevu/src/components/search/MobileFilters.tsx new file mode 100644 index 00000000..44c7c2e9 --- /dev/null +++ b/examples/klevu/src/components/search/MobileFilters.tsx @@ -0,0 +1,72 @@ +import { BreadcrumbLookup } from "../../lib/types/breadcrumb-lookup"; +import { Dialog, Transition } from "@headlessui/react"; +import { Dispatch, Fragment, SetStateAction } from "react"; +import { XMarkIcon } from "@heroicons/react/24/solid"; +import NodeMenu from "./NodeMenu"; + +interface IMobileFilters { + lookup?: BreadcrumbLookup; + showFilterMenu: boolean; + setShowFilterMenu: Dispatch>; +} + +export default function MobileFilters({ + showFilterMenu, + setShowFilterMenu, +}: IMobileFilters): JSX.Element { + return ( + + setShowFilterMenu(false)} + > + +
    + +
    +
    +
    + + +
    + +
    + +
    + Category + +
    +
    +
    +
    +
    +
    +
    +
    + ); +} diff --git a/examples/klevu/src/components/search/NoResults.tsx b/examples/klevu/src/components/search/NoResults.tsx new file mode 100644 index 00000000..535b0cac --- /dev/null +++ b/examples/klevu/src/components/search/NoResults.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +export const NoResults = (): JSX.Element => { + return ( +
    +
    + No matching results +
    +
    + ); +}; + +export default NoResults; diff --git a/examples/klevu/src/components/search/NodeMenu.tsx b/examples/klevu/src/components/search/NodeMenu.tsx new file mode 100644 index 00000000..3ea1ddb3 --- /dev/null +++ b/examples/klevu/src/components/search/NodeMenu.tsx @@ -0,0 +1,100 @@ +import { clsx } from "clsx"; +import { useFacetClicked, usePageContext } from "./ProductsProvider"; +import { KlevuFilterResultOptions } from "@klevu/core"; +import { Facet } from "./product-specification/Facets"; + +function isPathActive(activePath: string, providedPath: string): boolean { + const targetPathParts = activePath.split("/").filter(Boolean); + const providedPathParts = providedPath.split("/").filter(Boolean); + + if (providedPathParts.length > targetPathParts.length) { + return false; + } + + return providedPathParts.every( + (part, index) => part === targetPathParts[index], + ); +} + +function MenuItem({ item, filter }: { item: KlevuFilterResultOptions | Facet, filter?: KlevuFilterResultOptions }): JSX.Element { + const activeItem = 'selected' in item ? item.selected : false; + const facetClicked = useFacetClicked(); + const label = 'label' in item ? item.label : item.name; + const options = 'options' in item ? item.options : []; + return ( +
  • + {filter && + } + {!filter &&
    { + if(filter) facetClicked(filter, item as Facet) + if(label === "All Products") facetClicked((item as any).categoryFilter, { name: "all" } as Facet)} + } + className={clsx( + "ais-HierarchicalMenu-link", + activeItem && clsx("font-bold text-brand-primary"), + )} + > + {label} +
    } + {!!options.length && ( +
    + +
    + )} +
  • + ); +} + +function MenuList({ items, filter }: { items: KlevuFilterResultOptions[] | Facet[], filter?: KlevuFilterResultOptions }) { + return ( +
      + {items.map((item) => ( + item ? : <> + ))} +
    + ); +} + +function isSelectedFacet(options: KlevuFilterResultOptions[]) { + return options?.some((option) => option?.options?.some((facet) => facet.selected)) +} + +export default function NodeMenu(): JSX.Element { + const pageContext = usePageContext(); + const filters = pageContext?.filters || []; + if(!filters.length) { + return <> + } + let categoryFilter = filters ? filters.find((filter) => filter.key === "category") : []; + const navWithAllProducts = [ + { + key: "all", + label: "All Products", + selected: !isSelectedFacet(filters as KlevuFilterResultOptions[]), + categoryFilter + } as any, + categoryFilter, + ]; + return ( +
    + +
    + ); +} diff --git a/examples/klevu/src/components/search/Pagination.tsx b/examples/klevu/src/components/search/Pagination.tsx new file mode 100644 index 00000000..e6e5f2db --- /dev/null +++ b/examples/klevu/src/components/search/Pagination.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { usePathname } from "next/navigation"; +import { + Pagination as DisplayPagination, + PaginationContent, + PaginationItem, PaginationLink, +} from "../pagination/Pagination"; +import { KlevuQueryResult } from "@klevu/core"; + +export const DEFAULT_LIMIT = 12; + +function calculateTotalPages(totalItems: number, limit: number): number { + // Ensure both totalItems and limit are positive integers + totalItems = Math.max(0, Math.floor(totalItems)); + limit = Math.max(1, Math.floor(limit)); + + // Calculate the total number of pages using the formula + return Math.ceil(totalItems / limit); +} + +export const Pagination = ({page}: {page: KlevuQueryResult}): JSX.Element => { + const pathname = usePathname(); + + if (!page) { + return <>; + } + + const totalPages = calculateTotalPages(page.meta.totalResultsFound, DEFAULT_LIMIT); + + return ( + + + {[...Array(totalPages).keys()].map((pageNumber) => ( + + {pageNumber + 1} + + ))} + + + ) +}; + +export default Pagination; diff --git a/examples/klevu/src/components/search/ProductsProvider.tsx b/examples/klevu/src/components/search/ProductsProvider.tsx new file mode 100644 index 00000000..bd2e8899 --- /dev/null +++ b/examples/klevu/src/components/search/ProductsProvider.tsx @@ -0,0 +1,192 @@ +import { + createContext, + useContext, + useEffect, + useState, +} from "react"; +import { FilterManager, KlevuFilterResultOptions, KlevuResponseObject, KlevuSearchOptions, KlevuSearchSorting } from "@klevu/core"; +import { fetchProducts } from "../../lib/klevu"; +import { usePathname, useSearchParams } from "next/navigation"; +import { DEFAULT_LIMIT } from "./Pagination"; +import { DEFAULT_MAX_VAL, DEFAULT_MIN_VAL } from "./price-range-slider/PriceRangeSlider"; +import { Facet } from "./product-specification/Facets"; + +type ProductsSettings = { + priceRange: number[]; + limit: number; + offset: number; + sortBy: KlevuSearchSorting.PriceAsc | KlevuSearchSorting.PriceDesc | undefined, +} + +type SettingsKey = keyof ProductsSettings + +interface ProductsState { + page?: { data: KlevuResponseObject | undefined, loading: boolean }; + settings: ProductsSettings; + adjustSettings: ((settings: Partial) => void); + facetClicked: (filter: KlevuFilterResultOptions, facet?: Facet) => void; +} + +export const ProductsProviderContext = createContext( null ); + +export type ProductsProviderProps = { + children: React.ReactNode; +}; + +function searchSettings(productsSettings: ProductsSettings): Partial { + const settings: Partial = { + limit: productsSettings.limit, + typeOfRecords: ["KLEVU_PRODUCT"], + offset: productsSettings.offset, + sort: productsSettings.sortBy || undefined, + }; + + if(productsSettings.priceRange[0] !== DEFAULT_MIN_VAL || productsSettings.priceRange[1] !== DEFAULT_MAX_VAL) { + settings.groupCondition = { + groupOperator: "ANY_OF", + conditions: [ + { + key: "klevu_price", + valueOperator: "INCLUDE", + singleSelect: true, + excludeValuesInResult: true, + values: [ + `${productsSettings.priceRange[0]} - ${productsSettings.priceRange[1]}` + ] + } + ] + } + } + return settings; +} + +function convertToTitleCase(text: string): string { + return text + .split('-') // Split the string by hyphens + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) // Capitalize the first letter of each word + .join(' '); // Join the words back together with spaces +} + +const manager = new FilterManager(); + +export const ProductsProvider = ({ + children, +}: ProductsProviderProps) => { + const [initRange, setInitRange] = useState<[Number, Number]>() + const searchParams = useSearchParams(); + const DEFAULT_SETTINGS: ProductsSettings = { + limit: Number(searchParams.get("limit")) || DEFAULT_LIMIT, + offset: Number(searchParams.get("offset")) || 0, + priceRange: [DEFAULT_MIN_VAL, DEFAULT_MAX_VAL], + sortBy: undefined, + } + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [page, setPage] = useState<{ data: KlevuResponseObject | undefined, loading: boolean }>({ data: undefined, loading: false}); + const pathname = usePathname(); + + const fetchProductsData = async () => { + try { + const data = await fetchProducts(searchSettings(settings), undefined, manager); + const priceRangeFilter: any = data.queriesById("search").filters?.find((filter) => filter.key === "klevu_price"); + if(!initRange) { + setInitRange([priceRangeFilter.min, priceRangeFilter.max]); + setPage({ data, loading: false }); + } else { + priceRangeFilter.min = initRange[0]; + priceRangeFilter.max = initRange[1]; + setPage({ data, loading: false }); + } + + } catch (error) { + console.error("Failed to fetch products:", error); + } + }; + + const adjustSettings = (newSettings: Partial) => { + const offset = newSettings.offset || 0; + setSettings({...settings, ...newSettings, offset}); + } + + useEffect(() => { + if(searchParams.get("offset")) { + adjustSettings({ offset: Number(searchParams.get("offset")) }); + } + }, [searchParams.get("offset")]) + + const facetClicked = (filter: KlevuFilterResultOptions, facet?: Facet) => { + if(facet?.selected) { + manager.deselectOption(filter.key, facet.value); + } else { + if(facet && facet.name === "all") { + filter.options.forEach((option) => { + manager.deselectOption(filter.key, option.value); + }) + } + else if(facet) { + manager.selectOption(filter.key, facet.value); + } + } + setSettings({...settings, offset: 0}) + } + + useEffect(() => { + if(manager && manager.filters?.length) { + const filter = manager.filters[0] as KlevuFilterResultOptions + filter.options.forEach((option) => manager.deselectOption("category", option.value)) + } + const pathSegments = pathname.replace('/search', '').split('/').filter(Boolean); + pathSegments.forEach((segment) => { + manager.selectOption("category", convertToTitleCase(segment)); + }); + }, []); + + useEffect(() => { + fetchProductsData(); + }, [settings]); + + return ( + + {children} + + ); +}; + +export function usePageContext() { + const context = useContext(ProductsProviderContext); + if (context === null) { + throw new Error("usePageContext must be used within a ProductsProvider"); + } + return context?.page?.data?.queriesById("search"); +} + +export function useResponseObject() { + const context = useContext(ProductsProviderContext); + if (context === null) { + throw new Error("useResponseObject must be used within a ProductsProvider"); + } + return context?.page; +} + +export function useFacetClicked() { + const context = useContext(ProductsProviderContext); + if (context === null) { + throw new Error("facetClicked must be used within a ProductsProvider"); + } + return context.facetClicked; +} + +export function useSettings(key: SettingsKey) { + const context = useContext(ProductsProviderContext); + if (context === null) { + throw new Error("settings must be used within a ProductsProvider"); + } + + const setSetting = (value: typeof context.settings[SettingsKey]) => { + context.adjustSettings({ [key]: value }); + }; + + return { + [key]: context.settings[key], + [`set${key.charAt(0).toUpperCase() + key.slice(1)}`]: setSetting + }; +} diff --git a/examples/klevu/src/components/search/SearchModal.tsx b/examples/klevu/src/components/search/SearchModal.tsx new file mode 100644 index 00000000..ed592570 --- /dev/null +++ b/examples/klevu/src/components/search/SearchModal.tsx @@ -0,0 +1,216 @@ +"use client"; +import { Fragment, useState } from "react"; +import { useRouter } from "next/navigation"; +import NoResults from "./NoResults"; +import { useDebouncedEffect } from "../../lib/use-debounced"; +import { EP_CURRENCY_CODE } from "../../lib/resolve-ep-currency-code"; +import { XMarkIcon, MagnifyingGlassIcon } from "@heroicons/react/24/solid"; +import Image from "next/image"; +import Link from "next/link"; +import clsx from "clsx"; +import { Dialog, Transition } from "@headlessui/react"; +import * as React from "react"; +import NoImage from "../NoImage"; +import { fetchProducts } from "../../lib/klevu"; +import { KlevuRecord } from "@klevu/core"; + +const SearchBox = ({ + onChange, + onSearchEnd, + setHits, +}: { + onChange: (value: string) => void; + onSearchEnd: (query: string) => void; + setHits: React.Dispatch> +}) => { + const [search, setSearch] = useState(""); + + const doSearch = async () => { + const resp = await fetchProducts({}, search); + setHits(resp.queriesById("search").records); + } + + useDebouncedEffect(() => { doSearch() }, 400, [search]); + + return ( +
    +
    + +
    + { + setSearch(event.target.value); + onChange(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + onSearchEnd(search); + } + }} + placeholder="Search" + /> +
    + +
    +
    + ); +}; + +const HitComponent = ({ hit, closeModal }: { hit: KlevuRecord, closeModal: () => void }) => { + const { price, image, name, sku, id } = hit; + + return ( + +
    +
    + {image ? ( + {name} + ) : ( + + )} +
    +
    + {name} +
    +
    + + {sku} + +
    +
    + {price && ( + + {price} + + )} +
    +
    + + ); +}; + +const Hits = ({ hits, closeModal }: { hits: KlevuRecord[], closeModal: () => void }) => { + if (hits.length) { + return ( +
      + {hits.map((hit) => ( +
    • + +
    • + ))} +
    + ); + } + return ; +}; + +export const SearchModal = (): JSX.Element => { + const [searchValue, setSearchValue] = useState(""); + const router = useRouter(); + const [hits, setHits] = useState([]) + let [isOpen, setIsOpen] = useState(false); + + function closeModal() { + setIsOpen(false); + } + + function openModal() { + setIsOpen(true); + } + + return ( +
    + + + + +
    + + +
    +
    + + + { + setSearchValue(value); + }} + onSearchEnd={(query) => { + closeModal(); + setSearchValue(""); + router.push(`/search?q=${query}`); + }} + setHits={setHits} + /> + {searchValue ? ( +
    +
    +
    + +
    +
    + ) : null} +
    +
    +
    +
    +
    +
    +
    + ); +}; + +export default SearchModal; \ No newline at end of file diff --git a/examples/klevu/src/components/search/SearchResults.tsx b/examples/klevu/src/components/search/SearchResults.tsx new file mode 100644 index 00000000..11801148 --- /dev/null +++ b/examples/klevu/src/components/search/SearchResults.tsx @@ -0,0 +1,116 @@ +"use client"; +import Hits from "./Hits"; +import Pagination from "./Pagination"; +import { Fragment, useState } from "react"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import NodeMenu from "./NodeMenu"; +import { ProductsProvider, usePageContext, useSettings } from "./ProductsProvider"; +import MobileFilters from "./MobileFilters"; +import NoResults from "./NoResults"; +import PriceRangeSlider from "./price-range-slider/PriceRangeSliderWrapper"; +import { Popover, Transition } from "@headlessui/react"; +import { sortByItems } from "../../lib/sort-by-items"; + +type sortBySetting = { + sortBy: string, + setSortBy: (value: string | undefined) => void +} + +export default function SearchResults(): JSX.Element { + let [showFilterMenu, setShowFilterMenu] = useState(false); + // const title = nodes ? resolveTitle(nodes, lookup) : "All Categories"; + const title = "Catalog" + + return ( + +
    +
    +
    + {title} +
    +
    +
    + + +
    + +
    +
    +
    +
    +
    +

    Category

    + + { // attribute={EP_ROUTE_PRICE} + } + +
    + + +
    +
    +
    + ); +} + +const HitsUI = (): JSX.Element => { + const pageContext = usePageContext(); + return
    + {pageContext?.records && + <> + +
    + +
    + + } + {!pageContext?.records && } +
    +} + +const SortBy = (): JSX.Element => { + const { setSortBy } = useSettings('sortBy') as sortBySetting; + return ( +
    + + {({}) => ( + <> + + Sort + + + +
    + {sortByItems.map((option) => ( + setSortBy(option.value)} + > + {option.label} + + ))} +
    +
    +
    + + )} +
    +
    ) +} diff --git a/examples/klevu/src/components/search/price-range-slider/PriceRangeSlider.tsx b/examples/klevu/src/components/search/price-range-slider/PriceRangeSlider.tsx new file mode 100644 index 00000000..c4e7bc95 --- /dev/null +++ b/examples/klevu/src/components/search/price-range-slider/PriceRangeSlider.tsx @@ -0,0 +1,70 @@ +import { MinusIcon } from "@heroicons/react/24/solid"; +import { useCallback, useState } from "react"; +import Slider from "rc-slider"; +import "rc-slider/assets/index.css"; +import { useSettings } from "../ProductsProvider"; +import throttle from "lodash/throttle"; + +export type PriceRange = [number, number]; + +export const DEFAULT_MIN_VAL = 0; +export const DEFAULT_MAX_VAL = 300; + +type PriceRangeSetting = { + priceRange: number[], + setPriceRange: (value: number | number[]) => void +} + +const PriceRangeSlider = () => { + const {priceRange, setPriceRange} = useSettings('priceRange') as PriceRangeSetting; + const [range, setRange] = useState(priceRange); + + const throttledSetPriceRange = useCallback( + throttle((minVal: number, maxVal: number) => { + setPriceRange([minVal, maxVal]); + }, 1000), + [] + ); + + const handleSliderChange = (val: number[], updatePriceRange?: boolean) => { + const [minVal, maxVal] = val; + setRange([minVal, maxVal]); + + if(updatePriceRange) { + throttledSetPriceRange(minVal, maxVal); + } + }; + + return ( +
    +
    + handleSliderChange([Number(e.target.value), range[1]], true)} + /> + + handleSliderChange([range[0], Number(e.target.value)], true)} + /> +
    + + { handleSliderChange(val as number[])}} + onChangeComplete={(val) => throttledSetPriceRange((val as number[])[0], (val as number[])[1])} + /> +
    + ); +}; + +export default PriceRangeSlider; diff --git a/examples/klevu/src/components/search/price-range-slider/PriceRangeSliderWrapper.tsx b/examples/klevu/src/components/search/price-range-slider/PriceRangeSliderWrapper.tsx new file mode 100644 index 00000000..977cd341 --- /dev/null +++ b/examples/klevu/src/components/search/price-range-slider/PriceRangeSliderWrapper.tsx @@ -0,0 +1,10 @@ +import PriceRangeSliderComponent, { PriceRange } from "./PriceRangeSlider"; + +export default function PriceRangeSliderWrapper(): JSX.Element { + return ( + <> +

    Price

    + + + ); +} diff --git a/examples/klevu/src/components/search/product-specification/Facets.tsx b/examples/klevu/src/components/search/product-specification/Facets.tsx new file mode 100644 index 00000000..60b5ad5b --- /dev/null +++ b/examples/klevu/src/components/search/product-specification/Facets.tsx @@ -0,0 +1,6 @@ +export type Facet = { +name: string; +value: string; +count: number; +selected: boolean; +} \ No newline at end of file diff --git a/examples/klevu/src/components/select/Select.tsx b/examples/klevu/src/components/select/Select.tsx new file mode 100644 index 00000000..e5195705 --- /dev/null +++ b/examples/klevu/src/components/select/Select.tsx @@ -0,0 +1,183 @@ +"use client"; + +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import { cn } from "../../lib/cn"; +import { ChevronUpIcon } from "@heroicons/react/20/solid"; +import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/outline"; +import { cva, VariantProps } from "class-variance-authority"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const selectTriggerVariants = cva( + "flex w-full text-black/80 rounded-lg justify-between items-center border border-black/40 focus-visible:ring-0 focus-visible:border-black bg-white disabled:cursor-not-allowed disabled:opacity-50 leading-[1.6rem]", + { + variants: { + sizeKind: { + default: "px-4 py-[0.78rem]", + medium: "px-4 py-[0.44rem]", + mediumUntilSm: "px-4 py-[0.44rem] sm:px-4 sm:py-[0.78rem]", + }, + }, + defaultVariants: { + sizeKind: "default", + }, + }, +); + +export type SelectTriggerProps = React.ComponentPropsWithoutRef< + typeof SelectPrimitive.Trigger +> & + VariantProps; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + SelectTriggerProps +>(({ className, children, sizeKind, ...props }, ref) => ( + span]:line-clamp-1", + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/examples/klevu/src/components/separator/Separator.tsx b/examples/klevu/src/components/separator/Separator.tsx new file mode 100644 index 00000000..8a98a5e1 --- /dev/null +++ b/examples/klevu/src/components/separator/Separator.tsx @@ -0,0 +1,29 @@ +"use client"; +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "../../lib/cn"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref, + ) => ( + + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/examples/klevu/src/components/shared/blurb.tsx b/examples/klevu/src/components/shared/blurb.tsx new file mode 100644 index 00000000..87df7aae --- /dev/null +++ b/examples/klevu/src/components/shared/blurb.tsx @@ -0,0 +1,76 @@ +import { ReactNode } from "react"; + +const Para = ({ children }: { children: ReactNode }) => { + return
    {children}
    ; +}; + +interface IBlurbProps { + title: string; +} + +const Blurb = ({ title }: IBlurbProps) => ( +
    +

    {title}

    + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec arcu + lectus, pharetra nec velit in, vehicula suscipit tellus. Quisque id mollis + magna. Cras nec lacinia ligula. Morbi aliquam tristique purus, nec dictum + metus euismod at. Vestibulum mollis metus lobortis lectus sodales + eleifend. Class aptent taciti sociosqu ad litora torquent per conubia + nostra, per inceptos himenaeos. Vivamus eget elementum eros, et ultricies + mi. Donec eget dolor imperdiet, gravida ante a, molestie tortor. Nullam + viverra, orci gravida sollicitudin auctor, urna magna condimentum risus, + vitae venenatis turpis mauris sed ligula. Fusce mattis, mauris ut eleifend + ullamcorper, dui felis tincidunt libero, ut commodo arcu leo a ligula. + Cras congue maximus magna, et porta nisl pulvinar in. Nam congue orci + ornare scelerisque elementum. Quisque purus justo, molestie ut leo at, + tristique pretium dui. + + + + Vestibulum imperdiet commodo egestas. Proin tincidunt leo non purus + euismod dictum. Vivamus sagittis mauris dolor, quis egestas purus placerat + eget. Mauris finibus scelerisque augue ut ultrices. Praesent vitae nulla + lorem. Ut eget accumsan risus, sed fringilla orci. Nunc volutpat, odio vel + ornare ullamcorper, massa mauris dapibus nunc, sed euismod lectus erat + eget ligula. Duis fringilla elit vel eleifend luctus. Quisque non blandit + magna. Vivamus pharetra, dolor sed molestie ultricies, tellus ex egestas + lacus, in posuere risus diam non massa. Phasellus in justo in urna + faucibus cursus. + + + + Nullam nibh nisi, lobortis at rhoncus ut, viverra at turpis. Mauris ac + sollicitudin diam. Phasellus non orci massa. Donec tincidunt odio justo. + Sed gravida leo turpis, vitae blandit sem pharetra sit amet. Vestibulum + ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; Orci varius natoque penatibus et magnis dis parturient montes, + nascetur ridiculus mus. + + + + In in pulvinar turpis, vel pulvinar ipsum. Praesent vel commodo nisi, id + maximus ex. Integer lorem augue, hendrerit et enim vel, eleifend blandit + felis. Integer egestas risus purus, ac rhoncus orci faucibus ac. + Pellentesque iaculis ligula a mauris aliquam, at ullamcorper est + vestibulum. Proin maximus sagittis purus ac pretium. Ut accumsan vitae + nisl sed viverra. + + + + Vivamus malesuada elit facilisis, fringilla lacus non, vulputate felis. + Curabitur dignissim quis ipsum eget pellentesque. Duis efficitur nec nisl + sit amet porta. Maecenas ac dui a felis finibus elementum feugiat at nibh. + Donec convallis sodales neque. Integer id libero eget diam finibus + tincidunt id id diam. Fusce ut lectus nisi. Donec orci enim, semper ac + feugiat vitae, dignissim non enim. Vestibulum commodo dolor nec sem + viverra gravida. Ut laoreet eu tortor auctor consequat. Nulla quis mauris + mollis, aliquam mi nec, laoreet ligula. Fusce laoreet lorem et malesuada + suscipit. Nullam convallis, risus a posuere ultrices, velit augue + porttitor ante, vitae lobortis ligula velit id justo. Praesent nec lorem + massa. + +
    +); + +export default Blurb; diff --git a/examples/klevu/src/components/sheet/Sheet.tsx b/examples/klevu/src/components/sheet/Sheet.tsx new file mode 100644 index 00000000..d117458f --- /dev/null +++ b/examples/klevu/src/components/sheet/Sheet.tsx @@ -0,0 +1,134 @@ +"use client"; + +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../../lib/cn"; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-[29.5rem]", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-[29.5rem]", + }, + }, + defaultVariants: { + side: "right", + }, + }, +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +); +SheetHeader.displayName = "SheetHeader"; + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +); +SheetFooter.displayName = "SheetFooter"; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/examples/klevu/src/components/shimmer.tsx b/examples/klevu/src/components/shimmer.tsx new file mode 100644 index 00000000..208e3f11 --- /dev/null +++ b/examples/klevu/src/components/shimmer.tsx @@ -0,0 +1,13 @@ +export const shimmer = (w: number, h: number) => ` + + + + + + + + + + + +`; diff --git a/examples/klevu/src/components/skeleton/Skeleton.tsx b/examples/klevu/src/components/skeleton/Skeleton.tsx new file mode 100644 index 00000000..788edc09 --- /dev/null +++ b/examples/klevu/src/components/skeleton/Skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "../../lib/cn"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
    + ); +} + +export { Skeleton }; diff --git a/examples/klevu/src/components/toast/toaster.tsx b/examples/klevu/src/components/toast/toaster.tsx new file mode 100644 index 00000000..758c60d7 --- /dev/null +++ b/examples/klevu/src/components/toast/toaster.tsx @@ -0,0 +1,23 @@ +"use client"; +import { useEffect } from "react"; +import { useEvent } from "@elasticpath/react-shopper-hooks"; +import { ToastContainer, toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; + +export function Toaster(): JSX.Element { + const { events } = useEvent(); + + useEffect(() => { + const sub = events.subscribe((event) => { + const toastFn = event.type === "success" ? toast.success : toast.error; + toastFn(`${"message" in event ? event.message : undefined}`, { + position: "bottom-center", + autoClose: 3000, + hideProgressBar: true, + }); + }); + return () => sub.unsubscribe(); + }, [events]); + + return ; +} diff --git a/examples/klevu/src/hooks/use-countries.tsx b/examples/klevu/src/hooks/use-countries.tsx new file mode 100644 index 00000000..0f836739 --- /dev/null +++ b/examples/klevu/src/hooks/use-countries.tsx @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { countries } from "../lib/all-countries"; + +export function useCountries() { + const storeCountries = useQuery({ + queryKey: ["countries"], + queryFn: () => { + /** + * Replace these with your own source for supported delivery countries. You can also fetch them from the API. + */ + return countries; + }, + }); + + return { + ...storeCountries, + }; +} diff --git a/examples/klevu/src/lib/all-countries.ts b/examples/klevu/src/lib/all-countries.ts new file mode 100644 index 00000000..0a772b3d --- /dev/null +++ b/examples/klevu/src/lib/all-countries.ts @@ -0,0 +1,258 @@ +export type CountryValue = { + name: string; + code: string; +}; + +export const countries: CountryValue[] = [ + { name: "Albania", code: "AL" }, + { name: "Åland Islands", code: "AX" }, + { name: "Algeria", code: "DZ" }, + { name: "American Samoa", code: "AS" }, + { name: "Andorra", code: "AD" }, + { name: "Angola", code: "AO" }, + { name: "Anguilla", code: "AI" }, + { name: "Antarctica", code: "AQ" }, + { name: "Antigua and Barbuda", code: "AG" }, + { name: "Argentina", code: "AR" }, + { name: "Armenia", code: "AM" }, + { name: "Aruba", code: "AW" }, + { name: "Australia", code: "AU" }, + { name: "Austria", code: "AT" }, + { name: "Azerbaijan", code: "AZ" }, + { name: "Bahamas (the)", code: "BS" }, + { name: "Bahrain", code: "BH" }, + { name: "Bangladesh", code: "BD" }, + { name: "Barbados", code: "BB" }, + { name: "Belarus", code: "BY" }, + { name: "Belgium", code: "BE" }, + { name: "Belize", code: "BZ" }, + { name: "Benin", code: "BJ" }, + { name: "Bermuda", code: "BM" }, + { name: "Bhutan", code: "BT" }, + { name: "Bolivia (Plurinational State of)", code: "BO" }, + { name: "Bonaire, Sint Eustatius and Saba", code: "BQ" }, + { name: "Bosnia and Herzegovina", code: "BA" }, + { name: "Botswana", code: "BW" }, + { name: "Bouvet Island", code: "BV" }, + { name: "Brazil", code: "BR" }, + { name: "British Indian Ocean Territory (the)", code: "IO" }, + { name: "Brunei Darussalam", code: "BN" }, + { name: "Bulgaria", code: "BG" }, + { name: "Burkina Faso", code: "BF" }, + { name: "Burundi", code: "BI" }, + { name: "Cabo Verde", code: "CV" }, + { name: "Cambodia", code: "KH" }, + { name: "Cameroon", code: "CM" }, + { name: "Canada", code: "CA" }, + { name: "Cayman Islands (the)", code: "KY" }, + { name: "Central African Republic (the)", code: "CF" }, + { name: "Chad", code: "TD" }, + { name: "Chile", code: "CL" }, + { name: "China", code: "CN" }, + { name: "Christmas Island", code: "CX" }, + { name: "Cocos (Keeling) Islands (the)", code: "CC" }, + { name: "Colombia", code: "CO" }, + { name: "Comoros (the)", code: "KM" }, + { name: "Congo (the Democratic Republic of the)", code: "CD" }, + { name: "Congo (the)", code: "CG" }, + { name: "Cook Islands (the)", code: "CK" }, + { name: "Costa Rica", code: "CR" }, + { name: "Croatia", code: "HR" }, + { name: "Cuba", code: "CU" }, + { name: "Curaçao", code: "CW" }, + { name: "Cyprus", code: "CY" }, + { name: "Czechia", code: "CZ" }, + { name: "Côte d'Ivoire", code: "CI" }, + { name: "Denmark", code: "DK" }, + { name: "Djibouti", code: "DJ" }, + { name: "Dominica", code: "DM" }, + { name: "Dominican Republic (the)", code: "DO" }, + { name: "Ecuador", code: "EC" }, + { name: "Egypt", code: "EG" }, + { name: "El Salvador", code: "SV" }, + { name: "Equatorial Guinea", code: "GQ" }, + { name: "Eritrea", code: "ER" }, + { name: "Estonia", code: "EE" }, + { name: "Eswatini", code: "SZ" }, + { name: "Ethiopia", code: "ET" }, + { name: "Falkland Islands (the) [Malvinas]", code: "FK" }, + { name: "Faroe Islands (the)", code: "FO" }, + { name: "Fiji", code: "FJ" }, + { name: "Finland", code: "FI" }, + { name: "France", code: "FR" }, + { name: "French Guiana", code: "GF" }, + { name: "French Polynesia", code: "PF" }, + { name: "French Southern Territories (the)", code: "TF" }, + { name: "Gabon", code: "GA" }, + { name: "Gambia (the)", code: "GM" }, + { name: "Georgia", code: "GE" }, + { name: "Germany", code: "DE" }, + { name: "Ghana", code: "GH" }, + { name: "Gibraltar", code: "GI" }, + { name: "Greece", code: "GR" }, + { name: "Greenland", code: "GL" }, + { name: "Grenada", code: "GD" }, + { name: "Guadeloupe", code: "GP" }, + { name: "Guam", code: "GU" }, + { name: "Guatemala", code: "GT" }, + { name: "Guernsey", code: "GG" }, + { name: "Guinea", code: "GN" }, + { name: "Guinea-Bissau", code: "GW" }, + { name: "Guyana", code: "GY" }, + { name: "Haiti", code: "HT" }, + { name: "Heard Island and McDonald Islands", code: "HM" }, + { name: "Holy See (the)", code: "VA" }, + { name: "Honduras", code: "HN" }, + { name: "Hong Kong", code: "HK" }, + { name: "Hungary", code: "HU" }, + { name: "Iceland", code: "IS" }, + { name: "India", code: "IN" }, + { name: "Indonesia", code: "ID" }, + { name: "Iran (Islamic Republic of)", code: "IR" }, + { name: "Iraq", code: "IQ" }, + { name: "Ireland", code: "IE" }, + { name: "Isle of Man", code: "IM" }, + { name: "Israel", code: "IL" }, + { name: "Italy", code: "IT" }, + { name: "Jamaica", code: "JM" }, + { name: "Japan", code: "JP" }, + { name: "Jersey", code: "JE" }, + { name: "Jordan", code: "JO" }, + { name: "Kazakhstan", code: "KZ" }, + { name: "Kenya", code: "KE" }, + { name: "Kiribati", code: "KI" }, + { name: "Korea (the Democratic People's Republic of)", code: "KP" }, + { name: "Korea (the Republic of)", code: "KR" }, + { name: "Kuwait", code: "KW" }, + { name: "Kyrgyzstan", code: "KG" }, + { name: "Lao People's Democratic Republic (the)", code: "LA" }, + { name: "Latvia", code: "LV" }, + { name: "Lebanon", code: "LB" }, + { name: "Lesotho", code: "LS" }, + { name: "Liberia", code: "LR" }, + { name: "Libya", code: "LY" }, + { name: "Liechtenstein", code: "LI" }, + { name: "Lithuania", code: "LT" }, + { name: "Luxembourg", code: "LU" }, + { name: "Macao", code: "MO" }, + { name: "Madagascar", code: "MG" }, + { name: "Malawi", code: "MW" }, + { name: "Malaysia", code: "MY" }, + { name: "Maldives", code: "MV" }, + { name: "Mali", code: "ML" }, + { name: "Malta", code: "MT" }, + { name: "Marshall Islands (the)", code: "MH" }, + { name: "Martinique", code: "MQ" }, + { name: "Mauritania", code: "MR" }, + { name: "Mauritius", code: "MU" }, + { name: "Mayotte", code: "YT" }, + { name: "Mexico", code: "MX" }, + { name: "Micronesia (Federated States of)", code: "FM" }, + { name: "Moldova (the Republic of)", code: "MD" }, + { name: "Monaco", code: "MC" }, + { name: "Mongolia", code: "MN" }, + { name: "Montenegro", code: "ME" }, + { name: "Montserrat", code: "MS" }, + { name: "Morocco", code: "MA" }, + { name: "Mozambique", code: "MZ" }, + { name: "Myanmar", code: "MM" }, + { name: "Namibia", code: "NA" }, + { name: "Nauru", code: "NR" }, + { name: "Nepal", code: "NP" }, + { name: "Netherlands (the)", code: "NL" }, + { name: "New Caledonia", code: "NC" }, + { name: "New Zealand", code: "NZ" }, + { name: "Nicaragua", code: "NI" }, + { name: "Niger (the)", code: "NE" }, + { name: "Nigeria", code: "NG" }, + { name: "Niue", code: "NU" }, + { name: "Norfolk Island", code: "NF" }, + { name: "Northern Mariana Islands (the)", code: "MP" }, + { name: "Norway", code: "NO" }, + { name: "Oman", code: "OM" }, + { name: "Pakistan", code: "PK" }, + { name: "Palau", code: "PW" }, + { name: "Palestine, State of", code: "PS" }, + { name: "Panama", code: "PA" }, + { name: "Papua New Guinea", code: "PG" }, + { name: "Paraguay", code: "PY" }, + { name: "Peru", code: "PE" }, + { name: "Philippines (the)", code: "PH" }, + { name: "Pitcairn", code: "PN" }, + { name: "Poland", code: "PL" }, + { name: "Portugal", code: "PT" }, + { name: "Puerto Rico", code: "PR" }, + { name: "Qatar", code: "QA" }, + { name: "Republic of North Macedonia", code: "MK" }, + { name: "Romania", code: "RO" }, + { name: "Russian Federation (the)", code: "RU" }, + { name: "Rwanda", code: "RW" }, + { name: "Réunion", code: "RE" }, + { name: "Saint Barthélemy", code: "BL" }, + { name: "Saint Helena, Ascension and Tristan da Cunha", code: "SH" }, + { name: "Saint Kitts and Nevis", code: "KN" }, + { name: "Saint Lucia", code: "LC" }, + { name: "Saint Martin (French part)", code: "MF" }, + { name: "Saint Pierre and Miquelon", code: "PM" }, + { name: "Saint Vincent and the Grenadines", code: "VC" }, + { name: "Samoa", code: "WS" }, + { name: "San Marino", code: "SM" }, + { name: "Sao Tome and Principe", code: "ST" }, + { name: "Saudi Arabia", code: "SA" }, + { name: "Senegal", code: "SN" }, + { name: "Serbia", code: "RS" }, + { name: "Seychelles", code: "SC" }, + { name: "Sierra Leone", code: "SL" }, + { name: "Singapore", code: "SG" }, + { name: "Sint Maarten (Dutch part)", code: "SX" }, + { name: "Slovakia", code: "SK" }, + { name: "Slovenia", code: "SI" }, + { name: "Solomon Islands", code: "SB" }, + { name: "Somalia", code: "SO" }, + { name: "South Africa", code: "ZA" }, + { name: "South Georgia and the South Sandwich Islands", code: "GS" }, + { name: "South Sudan", code: "SS" }, + { name: "Spain", code: "ES" }, + { name: "Sri Lanka", code: "LK" }, + { name: "Sudan (the)", code: "SD" }, + { name: "Suriname", code: "SR" }, + { name: "Svalbard and Jan Mayen", code: "SJ" }, + { name: "Sweden", code: "SE" }, + { name: "Switzerland", code: "CH" }, + { name: "Syrian Arab Republic", code: "SY" }, + { name: "Taiwan (Province of China)", code: "TW" }, + { name: "Tajikistan", code: "TJ" }, + { name: "Tanzania, United Republic of", code: "TZ" }, + { name: "Thailand", code: "TH" }, + { name: "Timor-Leste", code: "TL" }, + { name: "Togo", code: "TG" }, + { name: "Tokelau", code: "TK" }, + { name: "Tonga", code: "TO" }, + { name: "Trinidad and Tobago", code: "TT" }, + { name: "Tunisia", code: "TN" }, + { name: "Turkey", code: "TR" }, + { name: "Turkmenistan", code: "TM" }, + { name: "Turks and Caicos Islands (the)", code: "TC" }, + { name: "Tuvalu", code: "TV" }, + { name: "Uganda", code: "UG" }, + { name: "Ukraine", code: "UA" }, + { name: "United Arab Emirates (the)", code: "AE" }, + { + name: "United Kingdom of Great Britain and Northern Ireland (the)", + code: "GB", + }, + { name: "United States Minor Outlying Islands (the)", code: "UM" }, + { name: "United States of America (the)", code: "US" }, + { name: "Uruguay", code: "UY" }, + { name: "Uzbekistan", code: "UZ" }, + { name: "Vanuatu", code: "VU" }, + { name: "Venezuela (Bolivarian Republic of)", code: "VE" }, + { name: "Viet Nam", code: "VN" }, + { name: "Virgin Islands (British)", code: "VG" }, + { name: "Virgin Islands (U.S.)", code: "VI" }, + { name: "Wallis and Futuna", code: "WF" }, + { name: "Western Sahara", code: "EH" }, + { name: "Yemen", code: "YE" }, + { name: "Zambia", code: "ZM" }, + { name: "Zimbabwe", code: "ZW" }, +]; diff --git a/examples/klevu/src/lib/build-breadcrumb-lookup.ts b/examples/klevu/src/lib/build-breadcrumb-lookup.ts new file mode 100644 index 00000000..4d170da9 --- /dev/null +++ b/examples/klevu/src/lib/build-breadcrumb-lookup.ts @@ -0,0 +1,19 @@ +import { NavigationNode } from "./build-site-navigation"; +import { BreadcrumbLookup } from "./types/breadcrumb-lookup"; + +export function buildBreadcrumbLookup( + nodes: NavigationNode[], +): BreadcrumbLookup { + return nodes.reduce((acc, curr) => { + const { href, name, children, slug } = curr; + return { + ...acc, + [href]: { + href, + name, + slug, + }, + ...(children && buildBreadcrumbLookup(children)), + }; + }, {}); +} diff --git a/examples/klevu/src/lib/build-site-navigation.ts b/examples/klevu/src/lib/build-site-navigation.ts new file mode 100644 index 00000000..d50c7f34 --- /dev/null +++ b/examples/klevu/src/lib/build-site-navigation.ts @@ -0,0 +1,104 @@ +import type { Hierarchy,ElasticPath } from "@elasticpath/js-sdk"; +import { + getHierarchies, + getHierarchyChildren, + getHierarchyNodes, +} from "../services/hierarchy"; + +interface ISchema { + name: string; + slug: string; + href: string; + id: string; + children: ISchema[]; +} + +export interface NavigationNode { + name: string; + slug: string; + href: string; + id: string; + children: NavigationNode[]; +} + +export async function buildSiteNavigation( + client: ElasticPath, +): Promise { + // Fetch hierarchies to be used as top level nav + const hierarchies = await getHierarchies(client); + return constructTree(hierarchies, client); +} + +/** + * Construct hierarchy tree, limited to 5 hierarchies at the top level + */ +function constructTree( + hierarchies: Hierarchy[], + client: ElasticPath, +): Promise { + const tree = hierarchies + .slice(0, 4) + .map((hierarchy) => + createNode({ + name: hierarchy.attributes.name, + id: hierarchy.id, + slug: hierarchy.attributes.slug, + }), + ) + .map(async (hierarchy) => { + // Fetch first-level nav ('parent nodes') - the direct children of each hierarchy + const directChildren = await getHierarchyChildren(hierarchy.id, client); + // Fetch all nodes in each hierarchy (i.e. all 'child nodes' belonging to a hierarchy) + const allNodes = await getHierarchyNodes(hierarchy.id, client); + + // Build 2nd level by finding all 'child nodes' belonging to each first level featured-nodes + const directs = directChildren.slice(0, 4).map((child) => { + const children: ISchema[] = allNodes + .filter((node) => node?.relationships?.parent.data.id === child.id) + .map((node) => + createNode({ + name: node.attributes.name, + id: node.id, + slug: node.attributes.slug, + hrefBase: `${hierarchy.href}/${child.attributes.slug}`, + }), + ); + + return createNode({ + name: child.attributes.name, + id: child.id, + slug: child.attributes.slug, + hrefBase: hierarchy.href, + children, + }); + }); + + return { ...hierarchy, children: directs }; + }); + + return Promise.all(tree); +} + +interface CreateNodeDefinition { + name: string; + id: string; + slug?: string; + hrefBase?: string; + children?: ISchema[]; +} + +function createNode({ + name, + id, + slug = "missing-slug", + hrefBase = "", + children = [], +}: CreateNodeDefinition): ISchema { + return { + name, + id, + slug, + href: `${hrefBase}/${slug}`, + children, + }; +} diff --git a/examples/klevu/src/lib/cart-cookie-server.ts b/examples/klevu/src/lib/cart-cookie-server.ts new file mode 100644 index 00000000..86bf8f6f --- /dev/null +++ b/examples/klevu/src/lib/cart-cookie-server.ts @@ -0,0 +1,18 @@ +import "server-only"; +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; +import { cookies } from "next/headers"; + +const CART_COOKIE_NAME = `${COOKIE_PREFIX_KEY}_ep_cart`; + +/** + * The cart cookie is set by nextjs middleware. + */ +export function getCartCookieServer(): string { + const possibleCartCookie = cookies().get(CART_COOKIE_NAME); + + if (!possibleCartCookie) { + throw Error(`Failed to fetch cart cookie! key ${CART_COOKIE_NAME}`); + } + + return possibleCartCookie.value; +} diff --git a/examples/klevu/src/lib/cn.tsx b/examples/klevu/src/lib/cn.tsx new file mode 100644 index 00000000..a5ef1935 --- /dev/null +++ b/examples/klevu/src/lib/cn.tsx @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/examples/klevu/src/lib/color-lookup.ts b/examples/klevu/src/lib/color-lookup.ts new file mode 100644 index 00000000..9c147119 --- /dev/null +++ b/examples/klevu/src/lib/color-lookup.ts @@ -0,0 +1,10 @@ +export const colorLookup: { [key: string]: string } = { + gray: "gray", + grey: "gray", + red: "red", + white: "white", + teal: "teal", + purple: "purple", + green: "green", + blue: "blue", +}; diff --git a/examples/klevu/src/lib/connect-products-with-main-images.ts b/examples/klevu/src/lib/connect-products-with-main-images.ts new file mode 100644 index 00000000..f1976000 --- /dev/null +++ b/examples/klevu/src/lib/connect-products-with-main-images.ts @@ -0,0 +1,29 @@ +import { File, ProductResponse } from "@elasticpath/js-sdk"; +import { + ProductImageObject, + ProductResponseWithImage, +} from "./types/product-types"; + +export const connectProductsWithMainImages = ( + products: ProductResponse[], + images: File[], +): ProductResponseWithImage[] => { + // Object with image id as a key and File data as a value + let imagesObject: ProductImageObject = {}; + images.forEach((image) => { + imagesObject[image.id] = image; + }); + + const productList: ProductResponseWithImage[] = [...products]; + + productList.forEach((product) => { + if ( + product.relationships.main_image?.data && + imagesObject[product.relationships.main_image.data?.id] + ) { + product.main_image = + imagesObject[product.relationships.main_image.data?.id]; + } + }); + return productList; +}; diff --git a/examples/klevu/src/lib/constants.ts b/examples/klevu/src/lib/constants.ts new file mode 100644 index 00000000..eaaf64a1 --- /dev/null +++ b/examples/klevu/src/lib/constants.ts @@ -0,0 +1,6 @@ +export const DEFAULT_PAGINATION_LIMIT = 25; +export const TAGS = { + cart: "cart", + products: "products", + nodes: "nodes", +}; diff --git a/examples/klevu/src/lib/cookie-constants.ts b/examples/klevu/src/lib/cookie-constants.ts new file mode 100644 index 00000000..62392d7f --- /dev/null +++ b/examples/klevu/src/lib/cookie-constants.ts @@ -0,0 +1,4 @@ +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; + +export const CREDENTIALS_COOKIE_NAME = `${COOKIE_PREFIX_KEY}_ep_credentials`; +export const ACCOUNT_MEMBER_TOKEN_COOKIE_NAME = `${COOKIE_PREFIX_KEY}_ep_account_member_token`; diff --git a/examples/klevu/src/lib/create-breadcrumb.ts b/examples/klevu/src/lib/create-breadcrumb.ts new file mode 100644 index 00000000..f3c1c8e2 --- /dev/null +++ b/examples/klevu/src/lib/create-breadcrumb.ts @@ -0,0 +1,31 @@ +import { BreadcrumbLookup } from "./types/breadcrumb-lookup"; + +export interface BreadcrumbEntry { + value: string; + breadcrumb: string; + label: string; +} + +export function createBreadcrumb( + [head, ...tail]: string[], + lookup?: BreadcrumbLookup, + acc: BreadcrumbEntry[] = [], + breadcrumb?: string, +): BreadcrumbEntry[] { + const updatedBreadcrumb = `${breadcrumb ? `${breadcrumb}/` : ""}${head}`; + + const label = lookup?.[`/${updatedBreadcrumb}`]?.name ?? head; + + const entry = { + value: head, + breadcrumb: updatedBreadcrumb, + label, + }; + if (!head) { + return []; + } + if (tail.length < 1) { + return [...acc, entry]; + } + return createBreadcrumb(tail, lookup, [...acc, entry], updatedBreadcrumb); +} diff --git a/examples/klevu/src/lib/custom-rule-headers.ts b/examples/klevu/src/lib/custom-rule-headers.ts new file mode 100644 index 00000000..b320e365 --- /dev/null +++ b/examples/klevu/src/lib/custom-rule-headers.ts @@ -0,0 +1,17 @@ +import { isEmptyObj } from "./is-empty-object"; + +export function resolveEpccCustomRuleHeaders(): + | { "EP-Context-Tag"?: string; "EP-Channel"?: string } + | undefined { + const { epContextTag, epChannel } = { + epContextTag: process.env.NEXT_PUBLIC_CONTEXT_TAG, + epChannel: process.env.NEXT_PUBLIC_CHANNEL, + }; + + const headers = { + ...(epContextTag ? { "EP-Context-Tag": epContextTag } : {}), + ...(epChannel ? { "EP-Channel": epChannel } : {}), + }; + + return isEmptyObj(headers) ? undefined : headers; +} diff --git a/examples/klevu/src/lib/epcc-errors.ts b/examples/klevu/src/lib/epcc-errors.ts new file mode 100644 index 00000000..6f1f58c5 --- /dev/null +++ b/examples/klevu/src/lib/epcc-errors.ts @@ -0,0 +1,27 @@ +export function isNoDefaultCatalogError( + errors: object[], +): errors is [{ detail: string }] { + const error = errors[0]; + return ( + hasDetail(error) && + error.detail === + "unable to resolve default catalog: no default catalog id can be identified: not found" + ); +} + +function hasDetail(err: object): err is { detail: string } { + return "detail" in err; +} + +export function isEPError(err: unknown): err is { errors: object[] } { + return ( + typeof err === "object" && + !!err && + hasErrors(err) && + Array.isArray(err.errors) + ); +} + +function hasErrors(err: object): err is { errors: object[] } { + return "errors" in err; +} diff --git a/examples/klevu/src/lib/epcc-implicit-client.ts b/examples/klevu/src/lib/epcc-implicit-client.ts new file mode 100644 index 00000000..0067d84b --- /dev/null +++ b/examples/klevu/src/lib/epcc-implicit-client.ts @@ -0,0 +1,37 @@ +import { gateway, StorageFactory } from "@elasticpath/js-sdk"; +import { epccEnv } from "./resolve-epcc-env"; +import { resolveEpccCustomRuleHeaders } from "./custom-rule-headers"; +import { deleteCookie, getCookie, setCookie } from "cookies-next"; +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; +import { EP_CURRENCY_CODE } from "./resolve-ep-currency-code"; + +const headers = resolveEpccCustomRuleHeaders(); + +const { client_id, host } = epccEnv; + +export function getEpccImplicitClient() { + return gateway({ + name: COOKIE_PREFIX_KEY, + client_id, + host, + currency: EP_CURRENCY_CODE, + ...(headers ? { headers } : {}), + storage: createNextCookieStorageFactory(), + }); +} + +function createNextCookieStorageFactory(): StorageFactory { + return { + set: (key: string, value: string): void => { + setCookie(key, value, { + sameSite: "strict", + }); + }, + get: (key: string): any => { + return getCookie(key); + }, + delete: (key: string) => { + deleteCookie(key); + }, + }; +} diff --git a/examples/klevu/src/lib/epcc-server-client.ts b/examples/klevu/src/lib/epcc-server-client.ts new file mode 100644 index 00000000..4c59b599 --- /dev/null +++ b/examples/klevu/src/lib/epcc-server-client.ts @@ -0,0 +1,29 @@ +import { + ConfigOptions, + gateway as EPCCGateway, + MemoryStorageFactory, +} from "@elasticpath/js-sdk"; +import { epccEnv } from "./resolve-epcc-env"; +import { resolveEpccCustomRuleHeaders } from "./custom-rule-headers"; +import { EP_CURRENCY_CODE } from "./resolve-ep-currency-code"; + +const headers = resolveEpccCustomRuleHeaders(); + +const { client_id, client_secret, host } = epccEnv; + +if (typeof client_secret !== "string") { + throw Error( + "Attempted to use client credentials client without a defined client_secret. This is most likely caused by trying to use server side client on the client side.", + ); +} + +const config: ConfigOptions = { + client_id, + client_secret, + host, + currency: EP_CURRENCY_CODE, + storage: new MemoryStorageFactory(), + ...(headers ? { headers } : {}), +}; + +export const epccServerClient = EPCCGateway(config); diff --git a/examples/klevu/src/lib/epcc-server-side-credentials-client.ts b/examples/klevu/src/lib/epcc-server-side-credentials-client.ts new file mode 100644 index 00000000..13ca16ef --- /dev/null +++ b/examples/klevu/src/lib/epcc-server-side-credentials-client.ts @@ -0,0 +1,49 @@ +import "server-only"; +import { gateway, StorageFactory } from "@elasticpath/js-sdk"; +import { epccEnv } from "./resolve-epcc-env"; +import { resolveEpccCustomRuleHeaders } from "./custom-rule-headers"; +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; +import { EP_CURRENCY_CODE } from "./resolve-ep-currency-code"; +import { CREDENTIALS_COOKIE_NAME } from "./cookie-constants"; +import { cookies } from "next/headers"; + +const customHeaders = resolveEpccCustomRuleHeaders(); + +const { client_id, host, client_secret } = epccEnv; + +export function getServerSideCredentialsClient() { + const credentialsCookie = cookies().get(CREDENTIALS_COOKIE_NAME); + + return gateway({ + name: `${COOKIE_PREFIX_KEY}_creds`, + client_id, + client_secret, + host, + currency: EP_CURRENCY_CODE, + ...(customHeaders ? { headers: customHeaders } : {}), + reauth: false, + storage: createServerSideNextCookieStorageFactory(credentialsCookie?.value), + }); +} + +function createServerSideNextCookieStorageFactory( + initialCookieValue?: string, +): StorageFactory { + let state = new Map(); + + if (initialCookieValue) { + state.set(`${COOKIE_PREFIX_KEY}_ep_credentials`, initialCookieValue); + } + + return { + set: (key: string, value: string): void => { + state.set(key, value); + }, + get: (key: string): any => { + return state.get(key); + }, + delete: (key: string) => { + state.delete(key); + }, + }; +} diff --git a/examples/klevu/src/lib/epcc-server-side-implicit-client.ts b/examples/klevu/src/lib/epcc-server-side-implicit-client.ts new file mode 100644 index 00000000..dfba3600 --- /dev/null +++ b/examples/klevu/src/lib/epcc-server-side-implicit-client.ts @@ -0,0 +1,48 @@ +import "server-only"; +import { gateway, StorageFactory } from "@elasticpath/js-sdk"; +import { epccEnv } from "./resolve-epcc-env"; +import { resolveEpccCustomRuleHeaders } from "./custom-rule-headers"; +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; +import { EP_CURRENCY_CODE } from "./resolve-ep-currency-code"; +import { CREDENTIALS_COOKIE_NAME } from "./cookie-constants"; +import { cookies } from "next/headers"; + +const customHeaders = resolveEpccCustomRuleHeaders(); + +const { client_id, host } = epccEnv; + +export function getServerSideImplicitClient() { + const credentialsCookie = cookies().get(CREDENTIALS_COOKIE_NAME); + + return gateway({ + name: COOKIE_PREFIX_KEY, + client_id, + host, + currency: EP_CURRENCY_CODE, + ...(customHeaders ? { headers: customHeaders } : {}), + reauth: false, + storage: createServerSideNextCookieStorageFactory(credentialsCookie?.value), + }); +} + +function createServerSideNextCookieStorageFactory( + initialCookieValue?: string, +): StorageFactory { + let state = new Map(); + + if (initialCookieValue) { + state.set(`${COOKIE_PREFIX_KEY}_ep_credentials`, initialCookieValue); + } + + return { + set: (key: string, value: string): void => { + state.set(key, value); + }, + get: (key: string): any => { + return state.get(key); + }, + delete: (key: string) => { + state.delete(key); + }, + }; +} diff --git a/examples/klevu/src/lib/file-lookup.test.ts b/examples/klevu/src/lib/file-lookup.test.ts new file mode 100644 index 00000000..e5874188 --- /dev/null +++ b/examples/klevu/src/lib/file-lookup.test.ts @@ -0,0 +1,204 @@ +import { describe, test, expect } from "vitest"; +import { ProductResponse, File } from "@elasticpath/js-sdk"; +import { + getMainImageForProductResponse, + getOtherImagesForProductResponse, +} from "./file-lookup"; + +describe("file-lookup", () => { + test("getImagesForProductResponse should return the correct image file object", () => { + const productResp = { + id: "944cef1a-c906-4efc-b920-d2c489ec6181", + relationships: { + files: { + data: [ + { + created_at: "2022-05-27T08:16:58.110Z", + id: "0de087d5-253b-4f10-8a09-0c10ffd6e7fa", + type: "file", + }, + { + created_at: "2022-05-27T08:16:58.110Z", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + type: "file", + }, + { + created_at: "2023-10-28T14:19:20.832Z", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + type: "file", + }, + ], + }, + main_image: { + data: { + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + type: "main_image", + }, + }, + parent: { + data: { + id: "2f435914-03b5-4b9e-80cb-08d3baa4c1d3", + type: "product", + }, + }, + }, + } as Partial; + + const mainImage: Partial[] = [ + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + { + type: "file", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/e5b9fed7-fcef-44d7-9ab1-3b4a277baf21.jpg", + }, + }, + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + ]; + + expect( + getMainImageForProductResponse( + productResp as ProductResponse, + mainImage as File[], + ), + ).toEqual({ + type: "file", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/e5b9fed7-fcef-44d7-9ab1-3b4a277baf21.jpg", + }, + }); + }); + + test("getOtherImagesForProductResponse should return other images for product", () => { + const productResp = { + id: "944cef1a-c906-4efc-b920-d2c489ec6181", + relationships: { + files: { + data: [ + { + created_at: "2022-05-27T08:16:58.110Z", + id: "0de087d5-253b-4f10-8a09-0c10ffd6e7fa", + type: "file", + }, + { + created_at: "2022-05-27T08:16:58.110Z", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + type: "file", + }, + { + created_at: "2023-10-28T14:19:20.832Z", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + type: "file", + }, + ], + }, + main_image: { + data: { + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + type: "main_image", + }, + }, + parent: { + data: { + id: "2f435914-03b5-4b9e-80cb-08d3baa4c1d3", + type: "product", + }, + }, + }, + } as Partial; + + const files = [ + { + type: "file", + id: "0de087d5-253b-4f10-8a09-0c10ffd6e7fa", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/0de087d5-253b-4f10-8a09-0c10ffd6e7fa.jpeg", + }, + }, + { + type: "file", + id: "0de087d5-253b-4f10-8a09-0c10ffd6e7fa", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/0de087d5-253b-4f10-8a09-0c10ffd6e7fa.jpeg", + }, + }, + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + { + type: "file", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/e5b9fed7-fcef-44d7-9ab1-3b4a277baf21.jpg", + }, + }, + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + { + type: "file", + id: "d402c7e2-c8e9-46bc-93f4-30955cd0b9ec", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/d402c7e2-c8e9-46bc-93f4-30955cd0b9ec.jpg", + }, + }, + ] as Partial[]; + + expect( + getOtherImagesForProductResponse( + productResp as ProductResponse, + files as File[], + ), + ).toEqual([ + { + type: "file", + id: "0de087d5-253b-4f10-8a09-0c10ffd6e7fa", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/0de087d5-253b-4f10-8a09-0c10ffd6e7fa.jpeg", + }, + }, + { + type: "file", + id: "1fa7be8b-bdcf-43a0-8748-33e549d2c03e", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg", + }, + }, + { + type: "file", + id: "e5b9fed7-fcef-44d7-9ab1-3b4a277baf21", + link: { + href: "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/e5b9fed7-fcef-44d7-9ab1-3b4a277baf21.jpg", + }, + }, + ]); + }); +}); diff --git a/examples/klevu/src/lib/file-lookup.ts b/examples/klevu/src/lib/file-lookup.ts new file mode 100644 index 00000000..eb46afa4 --- /dev/null +++ b/examples/klevu/src/lib/file-lookup.ts @@ -0,0 +1,39 @@ +import { File, ProductResponse } from "@elasticpath/js-sdk"; + +export function getMainImageForProductResponse( + productResponse: ProductResponse, + mainImages: File[], +): File | undefined { + const mainImageId = productResponse.relationships?.main_image?.data?.id; + + if (!mainImageId) { + return; + } + + return lookupFileUsingId(mainImageId, mainImages); +} + +export function getOtherImagesForProductResponse( + productResponse: ProductResponse, + allFiles: File[], +): File[] | undefined { + const productFilesIdObj = productResponse.relationships?.files?.data ?? []; + + if (productFilesIdObj?.length === 0) { + return; + } + + return productFilesIdObj.reduce((acc, fileIdObj) => { + const file = lookupFileUsingId(fileIdObj.id, allFiles); + return [...acc, ...(file ? [file] : [])]; + }, [] as File[]); +} + +export function lookupFileUsingId( + fileId: string, + files: File[], +): File | undefined { + return files.find((file) => { + return file.id === fileId; + }); +} diff --git a/examples/klevu/src/lib/form-url-encode-body.ts b/examples/klevu/src/lib/form-url-encode-body.ts new file mode 100644 index 00000000..b05c4711 --- /dev/null +++ b/examples/klevu/src/lib/form-url-encode-body.ts @@ -0,0 +1,10 @@ +export function formUrlEncodeBody(body: Record): string { + return Object.keys(body) + .map( + (k) => + `${encodeURIComponent(k)}=${encodeURIComponent( + body[k as keyof typeof body], + )}`, + ) + .join("&"); +} diff --git a/examples/klevu/src/lib/format-currency.tsx b/examples/klevu/src/lib/format-currency.tsx new file mode 100644 index 00000000..fcdb76ba --- /dev/null +++ b/examples/klevu/src/lib/format-currency.tsx @@ -0,0 +1,20 @@ +import { Currency } from "@elasticpath/js-sdk"; + +export function formatCurrency( + amount: number, + currency: Currency, + options: { + locals?: Parameters[0]; + } = { locals: "en-US" }, +) { + const { decimal_places, code } = currency; + + const resolvedAmount = amount / Math.pow(10, decimal_places); + + return new Intl.NumberFormat(options.locals, { + style: "currency", + maximumFractionDigits: decimal_places, + minimumFractionDigits: decimal_places, + currency: code, + }).format(resolvedAmount); +} diff --git a/examples/klevu/src/lib/format-iso-date-string.ts b/examples/klevu/src/lib/format-iso-date-string.ts new file mode 100644 index 00000000..ef916c23 --- /dev/null +++ b/examples/klevu/src/lib/format-iso-date-string.ts @@ -0,0 +1,8 @@ +export function formatIsoDateString(isoString: string): string { + const dateObject = new Date(isoString); + return dateObject.toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} diff --git a/examples/klevu/src/lib/get-error-message.ts b/examples/klevu/src/lib/get-error-message.ts new file mode 100644 index 00000000..0d88bc36 --- /dev/null +++ b/examples/klevu/src/lib/get-error-message.ts @@ -0,0 +1,15 @@ +export function getErrorMessage(error: unknown): string { + let message: string; + + if (error instanceof Error) { + message = error.message; + } else if (error && typeof error === "object" && "message" in error) { + message = String(error.message); + } else if (typeof error === "string") { + message = error; + } else { + message = "Something went wrong."; + } + + return message; +} diff --git a/examples/klevu/src/lib/get-store-initial-state.ts b/examples/klevu/src/lib/get-store-initial-state.ts new file mode 100644 index 00000000..dafe837b --- /dev/null +++ b/examples/klevu/src/lib/get-store-initial-state.ts @@ -0,0 +1,20 @@ +import { ElasticPath } from "@elasticpath/js-sdk"; +import { InitialState } from "@elasticpath/react-shopper-hooks"; +import { buildSiteNavigation } from "./build-site-navigation"; +import { getCartCookieServer } from "./cart-cookie-server"; +import { getCart } from "../services/cart"; + +export async function getStoreInitialState( + client: ElasticPath, +): Promise { + const nav = await buildSiteNavigation(client); + + const cartCookie = getCartCookieServer(); + + const cart = await getCart(cartCookie, client); + + return { + cart, + nav, + }; +} diff --git a/examples/klevu/src/lib/is-account-member-authenticated.ts b/examples/klevu/src/lib/is-account-member-authenticated.ts new file mode 100644 index 00000000..ef12009b --- /dev/null +++ b/examples/klevu/src/lib/is-account-member-authenticated.ts @@ -0,0 +1,13 @@ +import type { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "./cookie-constants"; +import { parseAccountMemberCredentialsCookieStr } from "./retrieve-account-member-credentials"; + +export function isAccountMemberAuthenticated( + cookieStore: ReturnType, +): boolean { + const cookie = cookieStore.get(ACCOUNT_MEMBER_TOKEN_COOKIE_NAME); + const parsedCredentials = + cookie && parseAccountMemberCredentialsCookieStr(cookie.value); + + return !!parsedCredentials; +} diff --git a/examples/klevu/src/lib/is-empty-object.ts b/examples/klevu/src/lib/is-empty-object.ts new file mode 100644 index 00000000..200e3eb9 --- /dev/null +++ b/examples/klevu/src/lib/is-empty-object.ts @@ -0,0 +1,2 @@ +export const isEmptyObj = (obj: object): boolean => + Object.keys(obj).length === 0; diff --git a/examples/klevu/src/lib/is-supported-extension.ts b/examples/klevu/src/lib/is-supported-extension.ts new file mode 100644 index 00000000..a7b1adaf --- /dev/null +++ b/examples/klevu/src/lib/is-supported-extension.ts @@ -0,0 +1,9 @@ +export function isSupportedExtension(value: unknown): boolean { + return ( + typeof value === "boolean" || + typeof value === "number" || + typeof value === "string" || + typeof value === "undefined" || + value === null + ); +} diff --git a/examples/klevu/src/lib/klevu.ts b/examples/klevu/src/lib/klevu.ts new file mode 100644 index 00000000..de7e9692 --- /dev/null +++ b/examples/klevu/src/lib/klevu.ts @@ -0,0 +1,157 @@ +import { ShopperProduct } from "@elasticpath/react-shopper-hooks"; +import { KlevuConfig, KlevuFetch, similarProducts, search, trendingProducts, KlevuEvents, KlevuRecord, KlevuSearchOptions, FilterManager, sendSearchEvent, listFilters, applyFilterWithManager, recentlyViewed, newArrivals, kmcRecommendation, advancedFiltering, KlevuFilterResultOptions } from "@klevu/core"; + +export const initKlevu = () => { + KlevuConfig.init({ + url: `https://${process.env.NEXT_PUBLIC_KLEVU_SEARCH_URL!}/cs/v2/search`, + apiKey: process.env.NEXT_PUBLIC_KLEVU_API_KEY!, + }); + } + + export const fetchSimilarProducts = async (id: string) => { + const res = await KlevuFetch( + similarProducts([id]) + ); + + return res.apiResponse.queryResults; + }; + + export const fetchAllProducts = async () => { + const res = await KlevuFetch( + search( + '*', // Using '*' to match all products + { + typeOfRecords: ['KLEVU_PRODUCT'], + limit: 1000, + offset: 0, + } + ) + ); + + return res.apiResponse.queryResults; + }; + + export const fetchProducts = async (searchSettings: Partial, query?: string, manager?: FilterManager) => { + const selectedCategories = manager && manager.filters[0] ? (manager.filters[0] as KlevuFilterResultOptions).options.filter((option) => option.selected).map((option) => option.value) : []; + const res = await KlevuFetch( + search( + query || '*', + searchSettings, + sendSearchEvent(), + listFilters({ + rangeFilterSettings: [ + { + key: "klevu_price", + minMax: true, + }, + ], + ...(manager ? { filterManager: manager } : {}), + }), + // @ts-ignore + (manager && selectedCategories.length) ? advancedFiltering([ + { + key: "category", + singleSelect: true, + valueOperator: "INCLUDE", + values: selectedCategories, + } + ]) : () => null, + // @ts-ignore + manager ? applyFilterWithManager(manager) : () => null + ) + ); + + return res; + }; + + export const fetchFeatureProducts = async () => { + const trendingId = "trending" + new Date().getTime() + + const res = await KlevuFetch( + trendingProducts( + { + limit: 4, + id: trendingId, + }, + ) + ) + return res.queriesById(trendingId); + }; + + export const fetchRecentlyViewed = async () => { + const res = await KlevuFetch( + recentlyViewed() + ); + + return res.apiResponse.queryResults; + }; + + export const fetchNewArrivals = async () => { + const res = await KlevuFetch( + newArrivals() + ); + + return res.apiResponse.queryResults; + }; + + export const fetchKMCRecommendations = async (id: string) => { + const res = await KlevuFetch( + kmcRecommendation(id) + ); + + return res.apiResponse.queryResults; + }; + + export const sendClickEv = async(product: any) => { + KlevuEvents.searchProductClick({ + product, + searchTerm: undefined, + }) + } + + export const transformRecord = (record: KlevuRecord): ShopperProduct => { + const main_image: any = { + link: { + href: record.image + } + }; + + return { + main_image, + response: { + meta: { + display_price: { + without_tax: { + amount: Number(record.price), + formatted: record.price, + currency: record.currency + }, + with_tax: { + amount: Number(record.price), + formatted: record.price, + currency: record.currency + }, + }, + original_display_price: { + without_tax: { + amount: Number(record.salePrice), + formatted: record.price, + currency: record.currency + }, + with_tax: { + amount: Number(record.salePrice), + formatted: record.price, + currency: record.currency + }, + } + }, + attributes: { + name: record.name, + description: record.shortDesc + } as any, + id: record.id + } + } as ShopperProduct + } + + initKlevu(); \ No newline at end of file diff --git a/examples/klevu/src/lib/middleware/apply-set-cookie.ts b/examples/klevu/src/lib/middleware/apply-set-cookie.ts new file mode 100644 index 00000000..f6a1a400 --- /dev/null +++ b/examples/klevu/src/lib/middleware/apply-set-cookie.ts @@ -0,0 +1,31 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { + ResponseCookies, + RequestCookies, +} from "next/dist/server/web/spec-extension/cookies"; + +/** + * Copy cookies from the Set-Cookie header of the response to the Cookie header of the request, + * so that it will appear to SSR/RSC as if the user already has the new cookies. + * + * Workaround for - https://github.com/vercel/next.js/issues/49442#issuecomment-1679807704 + */ +export function applySetCookie(req: NextRequest, res: NextResponse): void { + // parse the outgoing Set-Cookie header + const setCookies = new ResponseCookies(res.headers); + // Build a new Cookie header for the request by adding the setCookies + const newReqHeaders = new Headers(req.headers); + const newReqCookies = new RequestCookies(newReqHeaders); + setCookies.getAll().forEach((cookie) => newReqCookies.set(cookie)); + // set “request header overrides” on the outgoing response + NextResponse.next({ + request: { headers: newReqHeaders }, + }).headers.forEach((value, key) => { + if ( + key === "x-middleware-override-headers" || + key.startsWith("x-middleware-request-") + ) { + res.headers.set(key, value); + } + }); +} diff --git a/examples/klevu/src/lib/middleware/cart-cookie-middleware.ts b/examples/klevu/src/lib/middleware/cart-cookie-middleware.ts new file mode 100644 index 00000000..f0faf431 --- /dev/null +++ b/examples/klevu/src/lib/middleware/cart-cookie-middleware.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + createAuthenticationErrorUrl, + createMissingEnvironmentVariableUrl, +} from "./create-missing-environment-variable-url"; +import { epccEndpoint } from "./implicit-auth-middleware"; +import { NextResponseFlowResult } from "./middleware-runner"; +import { tokenExpired } from "../token-expired"; +import { applySetCookie } from "./apply-set-cookie"; + +const cookiePrefixKey = process.env.NEXT_PUBLIC_COOKIE_PREFIX_KEY; + +export async function cartCookieMiddleware( + req: NextRequest, + previousResponse: NextResponse, +): Promise { + if (typeof cookiePrefixKey !== "string") { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createMissingEnvironmentVariableUrl( + "NEXT_PUBLIC_COOKIE_PREFIX_KEY", + req.nextUrl.basePath, + req.url, + ), + ), + }; + } + + if (req.cookies.get(`${cookiePrefixKey}_ep_cart`)) { + return { + shouldReturn: false, + resultingResponse: previousResponse, + }; + } + + if (typeof epccEndpoint !== "string") { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createMissingEnvironmentVariableUrl( + "NEXT_PUBLIC_EPCC_ENDPOINT_URL", + req.nextUrl.basePath, + req.url, + ), + ), + }; + } + + const authToken = retrieveAuthToken(req, previousResponse); + + if (!authToken) { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createAuthenticationErrorUrl( + `Cart cookie creation failed in middleware because credentials \"${cookiePrefixKey}_ep_credentials\" cookie was missing.`, + req.nextUrl.origin, + req.url, + ), + ), + }; + } + + if (!authToken.access_token) { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createAuthenticationErrorUrl( + `Cart cookie creation failed in middleware because credentials \"access_token\" was undefined.`, + req.nextUrl.origin, + req.url, + ), + ), + }; + } + + const createdCart = await fetch(`https://${epccEndpoint}/v2/carts`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ data: { name: "Cart" } }), + }); + + const parsedCartJSON = await createdCart.json(); + + previousResponse.cookies.set( + `${cookiePrefixKey}_ep_cart`, + parsedCartJSON.data.id, + { + sameSite: "strict", + expires: new Date(parsedCartJSON.data.meta.timestamps.expires_at), + }, + ); + + // Apply those cookies to the request + // Workaround for - https://github.com/vercel/next.js/issues/49442#issuecomment-1679807704 + applySetCookie(req, previousResponse); + + return { + shouldReturn: false, + resultingResponse: previousResponse, + }; +} + +function retrieveAuthToken( + req: NextRequest, + resp: NextResponse, +): { access_token: string; expires: number } | undefined { + const authCookie = + req.cookies.get(`${cookiePrefixKey}_ep_credentials`) ?? + resp.cookies.get(`${cookiePrefixKey}_ep_credentials`); + + const possiblyParsedCookie = authCookie && JSON.parse(authCookie.value); + + return possiblyParsedCookie && tokenExpired(possiblyParsedCookie.expires) + ? undefined + : possiblyParsedCookie; +} diff --git a/examples/klevu/src/lib/middleware/create-missing-environment-variable-url.ts b/examples/klevu/src/lib/middleware/create-missing-environment-variable-url.ts new file mode 100644 index 00000000..f6f347e5 --- /dev/null +++ b/examples/klevu/src/lib/middleware/create-missing-environment-variable-url.ts @@ -0,0 +1,36 @@ +import { NonEmptyArray } from "../types/non-empty-array"; + +export function createMissingEnvironmentVariableUrl( + name: string | NonEmptyArray, + reqUrl: string, + from?: string, +): URL { + const configErrorUrl = createBaseErrorUrl(reqUrl, from); + + (Array.isArray(name) ? name : [name]).forEach((n) => { + configErrorUrl.searchParams.append("missing-env-variable", n); + }); + + return configErrorUrl; +} + +export function createAuthenticationErrorUrl( + message: string, + reqUrl: string, + from?: string, +): URL { + const configErrorUrl = createBaseErrorUrl(reqUrl, from); + configErrorUrl.searchParams.append( + "authentication", + encodeURIComponent(message), + ); + return configErrorUrl; +} + +function createBaseErrorUrl(reqUrl: string, from?: string): URL { + const configErrorUrl = new URL("/configuration-error", reqUrl); + if (from) { + configErrorUrl.searchParams.set("from", from); + } + return configErrorUrl; +} diff --git a/examples/klevu/src/lib/middleware/implicit-auth-middleware.ts b/examples/klevu/src/lib/middleware/implicit-auth-middleware.ts new file mode 100644 index 00000000..22f38d5d --- /dev/null +++ b/examples/klevu/src/lib/middleware/implicit-auth-middleware.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { NextResponseFlowResult } from "./middleware-runner"; +import { formUrlEncodeBody } from "../form-url-encode-body"; +import { + createAuthenticationErrorUrl, + createMissingEnvironmentVariableUrl, +} from "./create-missing-environment-variable-url"; +import { tokenExpired } from "../token-expired"; +import { applySetCookie } from "./apply-set-cookie"; + +export const epccEndpoint = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL; +const clientId = process.env.NEXT_PUBLIC_EPCC_CLIENT_ID; +const cookiePrefixKey = process.env.NEXT_PUBLIC_COOKIE_PREFIX_KEY; + +export async function implicitAuthMiddleware( + req: NextRequest, + previousResponse: NextResponse, +): Promise { + if (typeof clientId !== "string" || typeof cookiePrefixKey !== "string") { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createMissingEnvironmentVariableUrl( + ["NEXT_PUBLIC_EPCC_CLIENT_ID", "NEXT_PUBLIC_COOKIE_PREFIX_KEY"], + req.nextUrl.basePath, + req.url, + ), + ), + }; + } + + const possibleImplicitCookie = req.cookies.get( + `${cookiePrefixKey}_ep_credentials`, + ); + + if ( + possibleImplicitCookie && + !tokenExpired(JSON.parse(possibleImplicitCookie.value).expires) + ) { + return { + shouldReturn: false, + resultingResponse: previousResponse, + }; + } + + const authResponse = await getTokenImplicitToken({ + grant_type: "implicit", + client_id: clientId, + }); + + const token = await authResponse.json(); + + /** + * Check response did not fail + */ + if (token && "errors" in token) { + return { + shouldReturn: true, + resultingResponse: NextResponse.redirect( + createAuthenticationErrorUrl( + `Implicit auth middleware failed to get access token.`, + req.nextUrl.origin, + req.url, + ), + ), + }; + } + + previousResponse.cookies.set( + `${cookiePrefixKey}_ep_credentials`, + JSON.stringify({ + ...token, + client_id: clientId, + }), + { + sameSite: "strict", + expires: new Date(token.expires * 1000), + }, + ); + + // Apply those cookies to the request + // Workaround for - https://github.com/vercel/next.js/issues/49442#issuecomment-1679807704 + applySetCookie(req, previousResponse); + + return { + shouldReturn: false, + resultingResponse: previousResponse, + }; +} + +async function getTokenImplicitToken(body: { + grant_type: "implicit"; + client_id: string; +}): Promise { + return fetch(`https://${epccEndpoint}/oauth/access_token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formUrlEncodeBody(body), + }); +} diff --git a/examples/klevu/src/lib/middleware/middleware-runner.ts b/examples/klevu/src/lib/middleware/middleware-runner.ts new file mode 100644 index 00000000..d6b8df45 --- /dev/null +++ b/examples/klevu/src/lib/middleware/middleware-runner.ts @@ -0,0 +1,63 @@ +import { NonEmptyArray } from "../types/non-empty-array"; +import { NextRequest, NextResponse } from "next/server"; + +export interface NextResponseFlowResult { + shouldReturn: boolean; + resultingResponse: NextResponse; +} + +interface RunnableMiddlewareEntryOptions { + exclude?: NonEmptyArray; +} + +interface RunnableMiddlewareEntry { + runnable: RunnableMiddleware; + options?: RunnableMiddlewareEntryOptions; +} + +export function middlewareRunner( + ...middleware: NonEmptyArray +) { + return async (req: NextRequest): Promise => { + let lastResult: NextResponseFlowResult = { + shouldReturn: false, + resultingResponse: NextResponse.next(), + }; + + for (const m of middleware) { + const toRun: RunnableMiddlewareEntry = + "runnable" in m ? m : { runnable: m }; + + const { runnable, options } = toRun; + + if (shouldRun(req.nextUrl.pathname, options?.exclude)) { + lastResult = await runnable(req, lastResult.resultingResponse); + } + + if (lastResult.shouldReturn) { + return lastResult.resultingResponse; + } + } + return lastResult.resultingResponse; + }; +} + +function shouldRun( + pathname: string, + excluded?: NonEmptyArray, +): boolean { + if (excluded) { + for (const path of excluded) { + if (pathname.startsWith(path)) { + return false; + } + } + } + + return true; +} + +type RunnableMiddleware = ( + req: NextRequest, + previousResponse: NextResponse, +) => Promise; diff --git a/examples/klevu/src/lib/product-context.ts b/examples/klevu/src/lib/product-context.ts new file mode 100644 index 00000000..7be48e43 --- /dev/null +++ b/examples/klevu/src/lib/product-context.ts @@ -0,0 +1,10 @@ +import { createContext } from "react"; +import { + ProductContextState, + ProductModalContextState, +} from "./types/product-types"; + +export const ProductContext = createContext(null); + +export const ProductModalContext = + createContext(null); diff --git a/examples/klevu/src/lib/product-helper.test.ts b/examples/klevu/src/lib/product-helper.test.ts new file mode 100644 index 00000000..a71ba895 --- /dev/null +++ b/examples/klevu/src/lib/product-helper.test.ts @@ -0,0 +1,273 @@ +import type { ProductResponse, Variation } from "@elasticpath/js-sdk"; +import { describe, test, expect } from "vitest"; +import { + allVariationsHaveSelectedOption, + getOptionsFromSkuId, + getSkuIdFromOptions, + isChildProductResource, + isSimpleProductResource, + mapOptionsToVariation, +} from "./product-helper"; + +describe("product-helpers", () => { + test("isChildProductResource should return false if it's a base product", () => { + const sampleProduct = { + attributes: { + base_product: true, + }, + } as ProductResponse; + expect(isChildProductResource(sampleProduct)).toEqual(false); + }); + + test("isChildProductResource should return false if it is a simple product", () => { + const sampleProduct = { + attributes: { + base_product: false, + }, + } as ProductResponse; + expect(isChildProductResource(sampleProduct)).toEqual(false); + }); + test("isChildProductResource should return true if it is a child product", () => { + const sampleProduct = { + attributes: { + base_product: false, + base_product_id: "123", + }, + } as ProductResponse; + expect(isChildProductResource(sampleProduct)).toEqual(true); + }); + + test("isSimpleProductResource should return true if it is a simple product", () => { + const sampleProduct = { + attributes: { + base_product: false, + }, + } as ProductResponse; + expect(isSimpleProductResource(sampleProduct)).toEqual(true); + }); + + test("isSimpleProductResource should return false if it is a base product", () => { + const sampleProduct = { + attributes: { + base_product: true, + }, + } as ProductResponse; + expect(isSimpleProductResource(sampleProduct)).toEqual(false); + }); + + test("isSimpleProductResource should return false if it is a child product", () => { + const sampleProduct = { + attributes: { + base_product: true, + base_product_id: "123", + }, + } as ProductResponse; + expect(isSimpleProductResource(sampleProduct)).toEqual(false); + }); + + test("getSkuIDFromOptions should return the id of the sku for the provided options.", () => { + const variationMatrixSample = { + "4252d475-2d0e-4cd2-99d3-19fba34ef211": { + "217883ce-55f1-4c34-8e00-e86c743f4dff": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "709e6cc6-a40c-4833-9469-b4abd0e7f67f", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "c05839f5-3eac-48f2-9d36-1bc2a481a213", + }, + "37b5bcf7-0b65-4e12-ad31-3052e27c107f": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "9e07495c-caf1-4f11-93c5-16cfeb63d492", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "b9bb984a-7a6d-4433-a445-1cde0383bece", + }, + }, + "693b16b8-a3b3-4419-ad03-61007a381c56": { + "217883ce-55f1-4c34-8e00-e86c743f4dff": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "2d864c10-146f-4905-859f-86e63c18abf4", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "42aef769-c97e-48a8-a3c4-2af8ad504ebb", + }, + }, + }; + + const options = [ + "693b16b8-a3b3-4419-ad03-61007a381c56", + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89", + "217883ce-55f1-4c34-8e00-e86c743f4dff", + ]; + + expect(getSkuIdFromOptions(options, variationMatrixSample)).toEqual( + "2d864c10-146f-4905-859f-86e63c18abf4", + ); + }); + test("getSkuIDFromOptions should return undefined when proveded valid but not found options.", () => { + const variationMatrixSample = { + "4252d475-2d0e-4cd2-99d3-19fba34ef211": { + "217883ce-55f1-4c34-8e00-e86c743f4dff": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "709e6cc6-a40c-4833-9469-b4abd0e7f67f", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "c05839f5-3eac-48f2-9d36-1bc2a481a213", + }, + "37b5bcf7-0b65-4e12-ad31-3052e27c107f": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "9e07495c-caf1-4f11-93c5-16cfeb63d492", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "b9bb984a-7a6d-4433-a445-1cde0383bece", + }, + }, + "693b16b8-a3b3-4419-ad03-61007a381c56": { + "217883ce-55f1-4c34-8e00-e86c743f4dff": { + "45e2612f-6bbf-4bc9-8803-80c5cf78ed89": + "2d864c10-146f-4905-859f-86e63c18abf4", + "8b6dfc96-11e6-455d-b042-e4137df3f13a": + "42aef769-c97e-48a8-a3c4-2af8ad504ebb", + }, + }, + }; + + const options = ["4252d475-2d0e-4cd2-99d3-19fba34ef211", "456", "789"]; + + expect(getSkuIdFromOptions(options, variationMatrixSample)).toEqual( + undefined, + ); + }); + test("getSkuIDFromOptions should return undefined when proveded empty options.", () => { + const variationMatrixSample = {}; + + expect(getSkuIdFromOptions([], variationMatrixSample)).toEqual(undefined); + }); + + test("getOptionsFromSkuId should return a list of options for given sku id and matrix.", () => { + const variationMatrixSample = { + "option-1": { + "option-3": { + "option-5": "709e6cc6-a40c-4833-9469-b4abd0e7f67f", + "option-6": "c05839f5-3eac-48f2-9d36-1bc2a481a213", + }, + "option-4": { + "option-5": "9e07495c-caf1-4f11-93c5-16cfeb63d492", + "option-6": "b9bb984a-7a6d-4433-a445-1cde0383bece", + }, + }, + "option-2": { + "option-3": { + "option-5": "2d864c10-146f-4905-859f-86e63c18abf4", + "option-6": "42aef769-c97e-48a8-a3c4-2af8ad504ebb", + }, + }, + }; + + const expectedOutput = ["option-2", "option-3", "option-6"]; + + expect( + getOptionsFromSkuId( + "42aef769-c97e-48a8-a3c4-2af8ad504ebb", + variationMatrixSample, + ), + ).toEqual(expectedOutput); + }); + + test("mapOptionsToVariation should return the object mapping varitions to the selected option.", () => { + const variations: Partial[] = [ + { + id: "variation-1", + name: "Generic Sizes", + options: [ + { + id: "option-1", + description: "Small size", + name: "SM", + modifiers: [], + }, + { + id: "option-2", + description: "Medium size", + name: "MD", + modifiers: [], + }, + ], + }, + { + id: "variation-2", + name: "Simple T-Shirt Sleeve Length", + options: [ + { + id: "option-3", + description: "Simple T-Shirt with short sleeves", + name: "Short", + modifiers: [], + }, + { + id: "option-4", + description: "Simple T-Shirt with long sleeves", + name: "Long", + modifiers: [], + }, + ], + }, + ]; + + const selectedOptions = ["option-2", "option-3"]; + + const expectedOutput = { + "variation-1": "option-2", + "variation-2": "option-3", + }; + + expect( + mapOptionsToVariation(selectedOptions, variations as Variation[]), + ).toEqual(expectedOutput); + }); + + test("allVariationsHaveSelectedOption should return true if all variations keys have a defined value for their key value pair.", () => { + const variations: Partial[] = [ + { + id: "variation-1", + name: "Generic Sizes", + options: [ + { + id: "option-1", + description: "Small size", + name: "SM", + modifiers: [], + }, + { + id: "option-2", + description: "Medium size", + name: "MD", + modifiers: [], + }, + ], + }, + { + id: "variation-2", + name: "Simple T-Shirt Sleeve Length", + options: [ + { + id: "option-3", + description: "Simple T-Shirt with short sleeves", + name: "Short", + modifiers: [], + }, + { + id: "option-4", + description: "Simple T-Shirt with long sleeves", + name: "Long", + modifiers: [], + }, + ], + }, + ]; + + const optionDict = { + "variation-1": "option-2", + "variation-2": "option-3", + }; + + expect( + allVariationsHaveSelectedOption(optionDict, variations as Variation[]), + ).toEqual(true); + }); +}); diff --git a/examples/klevu/src/lib/product-helper.ts b/examples/klevu/src/lib/product-helper.ts new file mode 100644 index 00000000..446683f1 --- /dev/null +++ b/examples/klevu/src/lib/product-helper.ts @@ -0,0 +1,81 @@ +import { CatalogsProductVariation, ProductResponse } from "@elasticpath/js-sdk"; +import { OptionDict } from "./types/product-types"; +import { MatrixObjectEntry, MatrixValue } from "./types/matrix-object-entry"; + +export const getSkuIdFromOptions = ( + options: string[], + matrix: MatrixObjectEntry | MatrixValue, +): string | undefined => { + if (typeof matrix === "string") { + return matrix; + } + + for (const currOption in options) { + const nestedMatrix = matrix[options[currOption]]; + if (nestedMatrix) { + return getSkuIdFromOptions(options, nestedMatrix); + } + } + + return undefined; +}; + +export const getOptionsFromSkuId = ( + skuId: string, + entry: MatrixObjectEntry | MatrixValue, + options: string[] = [], +): string[] | undefined => { + if (typeof entry === "string") { + return entry === skuId ? options : undefined; + } + + let acc: string[] | undefined; + Object.keys(entry).every((key) => { + const result = getOptionsFromSkuId(skuId, entry[key], [...options, key]); + if (result) { + acc = result; + return false; + } + return true; + }); + return acc; +}; + +// TODO refactor +export const mapOptionsToVariation = ( + options: string[], + variations: CatalogsProductVariation[], +): OptionDict => { + return variations.reduce( + (acc: OptionDict, variation: CatalogsProductVariation) => { + const x = variation.options.find((varOption) => + options.some((selectedOption) => varOption.id === selectedOption), + )?.id; + return { ...acc, [variation.id]: x ? x : "" }; + }, + {}, + ); +}; + +export function allVariationsHaveSelectedOption( + optionsDict: OptionDict, + variations: CatalogsProductVariation[], +): boolean { + return !variations.some((variation) => !optionsDict[variation.id]); +} + +export const isChildProductResource = (product: ProductResponse): boolean => + !product.attributes.base_product && !!product.attributes.base_product_id; + +export const isSimpleProductResource = (product: ProductResponse): boolean => + !product.attributes.base_product && !product.attributes.base_product_id; + +/** + * promise will resolve after 300ms. + */ +export const wait300 = new Promise((resolve) => { + const wait = setTimeout(() => { + clearTimeout(wait); + resolve(); + }, 300); +}); diff --git a/examples/klevu/src/lib/product-util.test.ts b/examples/klevu/src/lib/product-util.test.ts new file mode 100644 index 00000000..d4c4e4e8 --- /dev/null +++ b/examples/klevu/src/lib/product-util.test.ts @@ -0,0 +1,315 @@ +import type { + File, + ProductResponse, + ShopperCatalogResource, + Variation, +} from "@elasticpath/js-sdk"; +import { describe, test, expect } from "vitest"; +import { + createEmptyOptionDict, + excludeChildProducts, + filterBaseProducts, + getProductMainImage, + processImageFiles, +} from "./product-util"; + +describe("product util", () => { + describe("unit tests", () => { + test("processImageFiles should return only supported images without the main image", () => { + const files: Partial[] = [ + { + type: "file", + id: "123", + mime_type: "image/jpeg", + }, + { + type: "file", + id: "456", + mime_type: "image/gif", + }, + { + type: "file", + id: "789", + mime_type: "image/jpeg", + }, + { + type: "file", + id: "101112", + mime_type: "image/png", + }, + { + type: "file", + id: "131415", + mime_type: "image/svg+xml", + }, + { + type: "file", + id: "161718", + mime_type: "image/webp", + }, + { + type: "file", + id: "192021", + mime_type: "video/mp4", + }, + { + type: "file", + id: "222324", + mime_type: "application/pdf", + }, + { + type: "file", + id: "252627", + mime_type: "application/vnd.ms-excel", + }, + { + type: "file", + id: "282930", + mime_type: "application/vnd.ms-powerpoint", + }, + { + type: "file", + id: "313233", + mime_type: "application/msword", + }, + ]; + + const expected: Partial[] = [ + { + type: "file", + id: "456", + mime_type: "image/gif", + }, + { + type: "file", + id: "789", + mime_type: "image/jpeg", + }, + { + type: "file", + id: "101112", + mime_type: "image/png", + }, + { + type: "file", + id: "131415", + mime_type: "image/svg+xml", + }, + { + type: "file", + id: "161718", + mime_type: "image/webp", + }, + ]; + expect(processImageFiles(files as File[], "123")).toEqual(expected); + }); + + test("processImageFiles should support an undefined main image id", () => { + const files: Partial[] = [ + { + type: "file", + id: "123", + mime_type: "image/jpeg", + }, + { + type: "file", + id: "456", + mime_type: "image/gif", + }, + ]; + + const expected: Partial[] = [ + { + type: "file", + id: "123", + mime_type: "image/jpeg", + }, + { + type: "file", + id: "456", + mime_type: "image/gif", + }, + ]; + expect(processImageFiles(files as File[])).toEqual(expected); + }); + + test("getProductMainImage should return a products main image file", () => { + const mainImageFile: Partial = { + type: "file", + id: "123", + mime_type: "image/jpeg", + }; + + const productResp: Partial> = { + included: { + main_images: [mainImageFile] as File[], + }, + }; + + expect(getProductMainImage(productResp.included?.main_images)).toEqual( + mainImageFile, + ); + }); + + test("getProductMainImage should return null when product does not have main image included", () => { + const productResp: Partial> = { + included: {}, + }; + + expect(getProductMainImage(productResp.included?.main_images)).toEqual( + null, + ); + }); + + test("createEmptyOptionDict should return an OptionDict with all with variation keys assigned undefined values", () => { + const variations: Partial[] = [ + { + id: "variation-1", + name: "Generic Sizes", + options: [ + { + id: "option-1", + description: "Small size", + name: "SM", + modifiers: [], + }, + { + id: "option-2", + description: "Medium size", + name: "MD", + modifiers: [], + }, + ], + }, + { + id: "variation-2", + name: "Simple T-Shirt Sleeve Length", + options: [ + { + id: "option-3", + description: "Simple T-Shirt with short sleeves", + name: "Short", + modifiers: [], + }, + { + id: "option-4", + description: "Simple T-Shirt with long sleeves", + name: "Long", + modifiers: [], + }, + ], + }, + ]; + + const optionDict = { + "variation-1": undefined, + "variation-2": undefined, + }; + + expect(createEmptyOptionDict(variations as Variation[])).toEqual( + optionDict, + ); + }); + + test("filterBaseProducts should return only the base products from a list of ProductResponse", () => { + const products: any = [ + { + id: "123", + attributes: { + base_product: false, + base_product_id: "789", + }, + relationships: { + parent: { + data: { + id: "parent-id", + type: "product", + }, + }, + }, + }, + { + id: "456", + attributes: { + base_product: false, + }, + relationships: {}, + }, + { + id: "789", + attributes: { + base_product: true, + }, + relationships: {}, + }, + ]; + + const expected = [ + { + id: "789", + attributes: { + base_product: true, + }, + relationships: {}, + }, + ]; + const actual = filterBaseProducts(products as ProductResponse[]); + expect(actual).toEqual(expected); + }); + + test("excludeChildProducts should return only the products that are not child products", () => { + const products: any = [ + { + id: "123", + attributes: { + base_product: false, + base_product_id: "789", + }, + relationships: { + parent: { + data: { + id: "parent-id", + type: "product", + }, + }, + }, + }, + { + id: "456", + attributes: { + base_product: false, + }, + relationships: {}, + }, + { + id: "789", + attributes: { + base_product: true, + }, + relationships: {}, + }, + ]; + + const expected = [ + { + id: "456", + attributes: { + base_product: false, + }, + relationships: {}, + }, + { + id: "789", + attributes: { + base_product: true, + }, + relationships: {}, + }, + ]; + + expect(excludeChildProducts(products as ProductResponse[])).toEqual( + expected, + ); + }); + }); +}); diff --git a/examples/klevu/src/lib/product-util.ts b/examples/klevu/src/lib/product-util.ts new file mode 100644 index 00000000..7fea4739 --- /dev/null +++ b/examples/klevu/src/lib/product-util.ts @@ -0,0 +1,54 @@ +import type { + CatalogsProductVariation, + File, + ProductResponse, +} from "@elasticpath/js-sdk"; +import type { + IdentifiableBaseProduct, + OptionDict, +} from "./types/product-types"; + +export function processImageFiles(files: File[], mainImageId?: string) { + // filters out main image and keeps server order + const supportedMimeTypes = [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + "image/svg+xml", + ]; + return files.filter( + (fileEntry) => + fileEntry.id !== mainImageId && + supportedMimeTypes.some((type) => fileEntry.mime_type === type), + ); +} + +export function getProductMainImage( + mainImages: File[] | undefined, +): File | null { + return mainImages?.[0] || null; +} + +// Using existance of parent relationship property to filter because only child products seem to have this property. +export const filterBaseProducts = ( + products: ProductResponse[], +): IdentifiableBaseProduct[] => + products.filter( + (product: ProductResponse): product is IdentifiableBaseProduct => + product.attributes.base_product, + ); + +// Using existance of parent relationship property to filter because only child products seem to have this property. +export const excludeChildProducts = ( + products: ProductResponse[], +): IdentifiableBaseProduct[] => + products.filter( + (product: ProductResponse): product is IdentifiableBaseProduct => + !product?.relationships?.parent, + ); + +export const createEmptyOptionDict = ( + variations: CatalogsProductVariation[], +): OptionDict => + variations.reduce((acc, c) => ({ ...acc, [c.id]: undefined }), {}); diff --git a/examples/klevu/src/lib/resolve-cart-env.ts b/examples/klevu/src/lib/resolve-cart-env.ts new file mode 100644 index 00000000..d4f29eba --- /dev/null +++ b/examples/klevu/src/lib/resolve-cart-env.ts @@ -0,0 +1,11 @@ +export const COOKIE_PREFIX_KEY = cartEnv(); + +function cartEnv(): string { + const cookiePrefixKey = process.env.NEXT_PUBLIC_COOKIE_PREFIX_KEY; + if (!cookiePrefixKey) { + throw new Error( + `Failed to get cart cookie key environment variables cookiePrefixKey. \n Make sure you have set NEXT_PUBLIC_COOKIE_PREFIX_KEY`, + ); + } + return cookiePrefixKey; +} diff --git a/examples/klevu/src/lib/resolve-ep-currency-code.ts b/examples/klevu/src/lib/resolve-ep-currency-code.ts new file mode 100644 index 00000000..999cdaf3 --- /dev/null +++ b/examples/klevu/src/lib/resolve-ep-currency-code.ts @@ -0,0 +1,13 @@ +import { getCookie } from "cookies-next"; +import { COOKIE_PREFIX_KEY } from "./resolve-cart-env"; + +export const EP_CURRENCY_CODE = retrieveCurrency(); + +function retrieveCurrency(): string { + const currencyInCookie = getCookie(`${COOKIE_PREFIX_KEY}_ep_currency`); + return ( + (typeof currencyInCookie === "string" + ? currencyInCookie + : process.env.NEXT_PUBLIC_DEFAULT_CURRENCY_CODE) || "USD" + ); +} diff --git a/examples/klevu/src/lib/resolve-epcc-env.ts b/examples/klevu/src/lib/resolve-epcc-env.ts new file mode 100644 index 00000000..7852a5c9 --- /dev/null +++ b/examples/klevu/src/lib/resolve-epcc-env.ts @@ -0,0 +1,21 @@ +export const epccEnv = resolveEpccEnv(); + +function resolveEpccEnv(): { + client_id: string; + host?: string; + client_secret?: string; +} { + const { host, client_id, client_secret } = { + host: process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL, + client_id: process.env.NEXT_PUBLIC_EPCC_CLIENT_ID, + client_secret: process.env.EPCC_CLIENT_SECRET, + }; + + if (!client_id) { + throw new Error( + `Failed to get Elasticpath Commerce Cloud client_id environment variables client_id: \n Make sure you have set NEXT_PUBLIC_EPCC_CLIENT_ID`, + ); + } + + return { host, client_id, client_secret }; +} diff --git a/examples/klevu/src/lib/retrieve-account-member-credentials.ts b/examples/klevu/src/lib/retrieve-account-member-credentials.ts new file mode 100644 index 00000000..2666ad33 --- /dev/null +++ b/examples/klevu/src/lib/retrieve-account-member-credentials.ts @@ -0,0 +1,49 @@ +import { cookies } from "next/headers"; +import { + AccountMemberCredential, + AccountMemberCredentials, + accountMemberCredentialsSchema, +} from "../app/(auth)/account-member-credentials-schema"; + +export function getSelectedAccount( + memberCredentials: AccountMemberCredentials, +): AccountMemberCredential { + const selectedAccount = + memberCredentials.accounts[memberCredentials.selected]; + if (!selectedAccount) { + throw new Error("No selected account"); + } + return selectedAccount; +} + +export function retrieveAccountMemberCredentials( + cookieStore: ReturnType, + name: string, +) { + const accountMemberCookie = cookieStore.get(name); + + // Next.js cookieStore.delete replaces a cookie with an empty string so we need to check for that here. + if (!accountMemberCookie || !accountMemberCookie.value) { + return undefined; + } + + return parseAccountMemberCredentialsCookieStr(accountMemberCookie?.value); +} + +export function parseAccountMemberCredentialsCookieStr( + str: string, +): AccountMemberCredentials | undefined { + const parsedCookie = accountMemberCredentialsSchema.safeParse( + JSON.parse(str), + ); + + if (!parsedCookie.success) { + console.error( + "Failed to parse account member cookie: ", + parsedCookie.error, + ); + return undefined; + } + + return parsedCookie.data; +} diff --git a/examples/klevu/src/lib/sort-alphabetically.ts b/examples/klevu/src/lib/sort-alphabetically.ts new file mode 100644 index 00000000..a69ce476 --- /dev/null +++ b/examples/klevu/src/lib/sort-alphabetically.ts @@ -0,0 +1,4 @@ +export const sortAlphabetically = ( + a: { name: string }, + b: { name: string }, +): number => a.name.localeCompare(b.name); diff --git a/examples/klevu/src/lib/sort-by-items.ts b/examples/klevu/src/lib/sort-by-items.ts new file mode 100644 index 00000000..523791a5 --- /dev/null +++ b/examples/klevu/src/lib/sort-by-items.ts @@ -0,0 +1,13 @@ +import { KlevuSearchSorting } from "@klevu/core"; + +export const sortByItems = [ + { label: "Featured", value: undefined }, + { + label: "Price (Low to High)", + value: KlevuSearchSorting.PriceAsc, + }, + { + label: "Price (High to Low)", + value: KlevuSearchSorting.PriceDesc, + }, +]; diff --git a/examples/klevu/src/lib/to-base-64.ts b/examples/klevu/src/lib/to-base-64.ts new file mode 100644 index 00000000..87b9edc2 --- /dev/null +++ b/examples/klevu/src/lib/to-base-64.ts @@ -0,0 +1,4 @@ +export const toBase64 = (str: string): string => + typeof window === "undefined" + ? Buffer.from(str).toString("base64") + : window.btoa(str); diff --git a/examples/klevu/src/lib/token-expired.ts b/examples/klevu/src/lib/token-expired.ts new file mode 100644 index 00000000..694a760f --- /dev/null +++ b/examples/klevu/src/lib/token-expired.ts @@ -0,0 +1,3 @@ +export function tokenExpired(expires: number): boolean { + return Math.floor(Date.now() / 1000) >= expires; +} diff --git a/examples/klevu/src/lib/types/breadcrumb-lookup.ts b/examples/klevu/src/lib/types/breadcrumb-lookup.ts new file mode 100644 index 00000000..f59ca3c9 --- /dev/null +++ b/examples/klevu/src/lib/types/breadcrumb-lookup.ts @@ -0,0 +1,7 @@ +export interface BreadcrumbLookupEntry { + href: string; + name: string; + slug: string; +} + +export type BreadcrumbLookup = Record; diff --git a/examples/klevu/src/lib/types/deep-partial.ts b/examples/klevu/src/lib/types/deep-partial.ts new file mode 100644 index 00000000..e422dd51 --- /dev/null +++ b/examples/klevu/src/lib/types/deep-partial.ts @@ -0,0 +1,3 @@ +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; diff --git a/examples/klevu/src/lib/types/matrix-object-entry.ts b/examples/klevu/src/lib/types/matrix-object-entry.ts new file mode 100644 index 00000000..a214bbc1 --- /dev/null +++ b/examples/klevu/src/lib/types/matrix-object-entry.ts @@ -0,0 +1,5 @@ +export type MatrixValue = string; + +export interface MatrixObjectEntry { + [key: string]: MatrixObjectEntry | MatrixValue; +} diff --git a/examples/klevu/src/lib/types/non-empty-array.ts b/examples/klevu/src/lib/types/non-empty-array.ts new file mode 100644 index 00000000..af797df1 --- /dev/null +++ b/examples/klevu/src/lib/types/non-empty-array.ts @@ -0,0 +1,3 @@ +export interface NonEmptyArray extends Array { + 0: A; +} diff --git a/examples/klevu/src/lib/types/product-types.ts b/examples/klevu/src/lib/types/product-types.ts new file mode 100644 index 00000000..e2cc5337 --- /dev/null +++ b/examples/klevu/src/lib/types/product-types.ts @@ -0,0 +1,31 @@ +import type { ProductResponse, File } from "@elasticpath/js-sdk"; +import type { Dispatch, SetStateAction } from "react"; + +export type IdentifiableBaseProduct = ProductResponse & { + id: string; + attributes: { slug: string; sku: string; base_product: true }; +}; + +export interface ProductContextState { + isChangingSku: boolean; + setIsChangingSku: Dispatch>; +} + +export interface ProductModalContextState { + isChangingSku: boolean; + setIsChangingSku: Dispatch>; + changedSkuId: string; + setChangedSkuId: Dispatch>; +} + +export interface OptionDict { + [key: string]: string; +} + +export interface ProductResponseWithImage extends ProductResponse { + main_image?: File; +} + +export interface ProductImageObject { + [key: string]: File; +} diff --git a/examples/klevu/src/lib/types/read-only-non-empty-array.ts b/examples/klevu/src/lib/types/read-only-non-empty-array.ts new file mode 100644 index 00000000..9877640a --- /dev/null +++ b/examples/klevu/src/lib/types/read-only-non-empty-array.ts @@ -0,0 +1,7 @@ +export type ReadonlyNonEmptyArray = ReadonlyArray & { + readonly 0: A; +}; + +export const isNonEmpty = ( + as: ReadonlyArray, +): as is ReadonlyNonEmptyArray => as.length > 0; diff --git a/examples/klevu/src/lib/types/unpacked.ts b/examples/klevu/src/lib/types/unpacked.ts new file mode 100644 index 00000000..733a5208 --- /dev/null +++ b/examples/klevu/src/lib/types/unpacked.ts @@ -0,0 +1,7 @@ +/** + * https://stackoverflow.com/a/52331580/4330441 + * Extract the type of array e.g. + * type Group = Item[] + * type MyItem = Unpacked + */ +export type Unpacked = T extends (infer U)[] ? U : T; diff --git a/examples/klevu/src/lib/use-debounced.ts b/examples/klevu/src/lib/use-debounced.ts new file mode 100644 index 00000000..9f963143 --- /dev/null +++ b/examples/klevu/src/lib/use-debounced.ts @@ -0,0 +1,14 @@ +import { DependencyList, EffectCallback, useEffect } from "react"; + +export const useDebouncedEffect = ( + effect: EffectCallback, + delay: number, + deps?: DependencyList, +) => { + useEffect(() => { + const handler = setTimeout(() => effect(), delay); + + return () => clearTimeout(handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...(deps || []), delay]); +}; diff --git a/examples/klevu/src/middleware.ts b/examples/klevu/src/middleware.ts new file mode 100644 index 00000000..a06a5712 --- /dev/null +++ b/examples/klevu/src/middleware.ts @@ -0,0 +1,21 @@ +import { NextRequest } from "next/server"; +import { middlewareRunner } from "./lib/middleware/middleware-runner"; +import { implicitAuthMiddleware } from "./lib/middleware/implicit-auth-middleware"; +import { cartCookieMiddleware } from "./lib/middleware/cart-cookie-middleware"; + +export async function middleware(req: NextRequest) { + return middlewareRunner( + { + runnable: implicitAuthMiddleware, + options: { + exclude: ["/_next", "/configuration-error"], + }, + }, + { + runnable: cartCookieMiddleware, + options: { + exclude: ["/_next", "/configuration-error"], + }, + }, + )(req); +} diff --git a/examples/klevu/src/services/cart.ts b/examples/klevu/src/services/cart.ts new file mode 100644 index 00000000..e92757e4 --- /dev/null +++ b/examples/klevu/src/services/cart.ts @@ -0,0 +1,9 @@ +import type {ElasticPath } from "@elasticpath/js-sdk"; +import { Cart, CartIncluded, ResourceIncluded } from "@elasticpath/js-sdk"; + +export async function getCart( + cartId: string, + client: ElasticPath, +): Promise> { + return client.Cart(cartId).With("items").Get(); +} diff --git a/examples/klevu/src/services/hierarchy.ts b/examples/klevu/src/services/hierarchy.ts new file mode 100644 index 00000000..6fafee61 --- /dev/null +++ b/examples/klevu/src/services/hierarchy.ts @@ -0,0 +1,28 @@ +import type { Node, Hierarchy } from "@elasticpath/js-sdk"; +import {ElasticPath } from "@elasticpath/js-sdk"; + +export async function getHierarchies(client: ElasticPath): Promise { + const result = await client.ShopperCatalog.Hierarchies.All(); + return result.data; +} + +export async function getHierarchyChildren( + hierarchyId: string, + client: ElasticPath, +): Promise { + const result = await client.ShopperCatalog.Hierarchies.GetHierarchyChildren({ + hierarchyId, + }); + return result.data; +} + +export async function getHierarchyNodes( + hierarchyId: string, + client: ElasticPath, +): Promise { + const result = await client.ShopperCatalog.Hierarchies.GetHierarchyNodes({ + hierarchyId, + }); + + return result.data; +} diff --git a/examples/klevu/src/services/products.ts b/examples/klevu/src/services/products.ts new file mode 100644 index 00000000..c8f49f64 --- /dev/null +++ b/examples/klevu/src/services/products.ts @@ -0,0 +1,68 @@ +import type { + ProductResponse, + ResourcePage, + ShopperCatalogResource, +} from "@elasticpath/js-sdk"; +import { wait300 } from "../lib/product-helper"; +import { ElasticPath } from "@elasticpath/js-sdk"; + +export async function getProductById( + productId: string, + client: ElasticPath, +): Promise> { + return client.ShopperCatalog.Products.With([ + "main_image", + "files", + "component_products", + ]).Get({ + productId, + }); +} + +export function getAllProducts(client: ElasticPath): Promise { + return _getAllProductPages(client)(); +} + +export function getProducts(client: ElasticPath, offset = 0, limit = 100) { + return client.ShopperCatalog.Products.With(["main_image"]) + .Limit(limit) + .Offset(offset) + .All(); +} + +const _getAllPages = + ( + nextPageRequestFn: ( + limit: number, + offset: number, + client?: ElasticPath, + ) => Promise>, + ) => + async ( + offset: number = 0, + limit: number = 25, + accdata: T[] = [], + ): Promise => { + const requestResp = await nextPageRequestFn(limit, offset); + const { + meta: { + page: newPage, + results: { total }, + }, + data: newData, + } = requestResp; + + const updatedOffset = offset + newPage.total; + const combinedData = [...accdata, ...newData]; + if (updatedOffset < total) { + return wait300.then(() => + _getAllPages(nextPageRequestFn)(updatedOffset, limit, combinedData), + ); + } + return Promise.resolve(combinedData); + }; + +const _getAllProductPages = (client: ElasticPath) => + _getAllPages((limit = 25, offset = 0) => + client.ShopperCatalog.Products.Limit(limit).Offset(offset).All(), + ); diff --git a/examples/klevu/src/styles/globals.css b/examples/klevu/src/styles/globals.css new file mode 100644 index 00000000..766ef9db --- /dev/null +++ b/examples/klevu/src/styles/globals.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + padding: 0; + margin: 0; + height: 100%; + font-family: + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Roboto, + Oxygen, + Ubuntu, + Cantarell, + Fira Sans, + Droid Sans, + Helvetica Neue, + sans-serif; +} + +.carousel__slide-focus-ring { + display: none !important; + outline-width: 0 !important; +} diff --git a/examples/klevu/tailwind.config.ts b/examples/klevu/tailwind.config.ts new file mode 100644 index 00000000..fc51cc8d --- /dev/null +++ b/examples/klevu/tailwind.config.ts @@ -0,0 +1,78 @@ +import plugin from "tailwindcss/plugin"; +import type { Config } from "tailwindcss"; + +export default { + content: ["./src/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: { + maxWidth: { + "base-max-width": "80rem", + }, + flex: { + "only-grow": "1 0 0%", + }, + colors: { + brand: { + primary: "#2BCC7E", + secondary: "#144E31", + highlight: "#56DC9B", + primaryAlt: "#EA7317", + secondaryAlt: "#ffcb47", + gray: "#666666", + }, + }, + keyframes: { + fadeIn: { + from: { opacity: "0" }, + to: { opacity: "1" }, + }, + marquee: { + "0%": { transform: "translateX(0%)" }, + "100%": { transform: "translateX(-100%)" }, + }, + blink: { + "0%": { opacity: "0.2" }, + "20%": { opacity: "1" }, + "100% ": { opacity: "0.2" }, + }, + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + fadeIn: "fadeIn .3s ease-in-out", + carousel: "marquee 60s linear infinite", + blink: "blink 1.4s both infinite", + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + clipPath: { + sidebar: "polygon(0 0, 100vmax 0, 100vmax 100%, 0 100%)", + }, + }, + }, + plugins: [ + require("@tailwindcss/forms"), + require("tailwindcss-animate"), + require("tailwind-clip-path"), + plugin(({ matchUtilities, theme }) => { + matchUtilities( + { + "animation-delay": (value) => { + return { + "animation-delay": value, + }; + }, + }, + { + values: theme("transitionDelay"), + }, + ); + }), + ], +} satisfies Config; diff --git a/examples/klevu/tsconfig.json b/examples/klevu/tsconfig.json new file mode 100644 index 00000000..36c7f895 --- /dev/null +++ b/examples/klevu/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "es6", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "src/**/*.ts", + "src/**/*.tsx", + ".next/types/**/*.ts", + "tailwind.config.ts" + ], + "exclude": ["node_modules"], + "types": ["global.d.ts"] +} diff --git a/examples/klevu/vite.config.ts b/examples/klevu/vite.config.ts new file mode 100644 index 00000000..9da935db --- /dev/null +++ b/examples/klevu/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig, defaultExclude } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["e2e/**/*", ...defaultExclude], + coverage: { + provider: "istanbul", + }, + }, +}); diff --git a/packages/d2c-schematics/workspace/files/next.config.js.template b/packages/d2c-schematics/workspace/files/next.config.js.template index 586e89a7..d7906785 100644 --- a/packages/d2c-schematics/workspace/files/next.config.js.template +++ b/packages/d2c-schematics/workspace/files/next.config.js.template @@ -15,6 +15,10 @@ const nextConfig = { protocol: "https", hostname: "**.cm.elasticpath.com", }, + { + protocol: "https", + hostname: "cdn.shopify.com", + }, ], }, i18n: { diff --git a/scripts/example-specs/configuration.ts b/scripts/example-specs/configuration.ts index 4408b9de..314d70ea 100644 --- a/scripts/example-specs/configuration.ts +++ b/scripts/example-specs/configuration.ts @@ -8,6 +8,7 @@ import type { } from "../../dist-schema/packages/d2c-schematics/checkout/schema" import type { Schema as PLPSchema } from "../../dist-schema/packages/d2c-schematics/product-list-page/schema" import type { Schema as PLPAlgoliaSchema } from "../../dist-schema/packages/d2c-schematics/product-list-page-algolia/schema" +import type { Schema as KlevuAlgoliaSchema } from "../../dist-schema/packages/d2c-schematics/product-list-page-klevu/schema" interface CLIArgs { dryRun: boolean @@ -27,6 +28,10 @@ type AlgoliaSpec = ConfigurationSpecHelper< AppSchema & D2CSchema & CLIArgs & PLPSchema & PLPAlgoliaSchema > +type KlevuSpec = ConfigurationSpecHelper< + AppSchema & D2CSchema & CLIArgs & PLPSchema & KlevuAlgoliaSchema +> + type Spec> = { name: string args: TArgs @@ -36,7 +41,7 @@ interface Configuration> { specs: Spec[] } -export const configuration: Configuration = { +export const configuration: Configuration = { specs: [ { name: "simple", @@ -96,5 +101,43 @@ export const configuration: Configuration = { packageManager: "pnpm", }, }, + { + name: "Klevu", + args: { + epccClientId: process.env.EPCC_CLIENT_ID, + epccClientSecret: process.env.EPCC_CLIENT_SECRET, + epccEndpointUrl: process.env.EPCC_ENDPOINT, + skipGit: true, + skipInstall: true, + skipConfig: true, + name: "klevu", + dryRun: false, + interactive: false, + plpType: "Klevu" as PlpType.Klevu, + klevuApiKey: process.env.KLEVU_API_KEY, + klevuSearchUrl: process.env.KLEVU_SEARCH_URL, + paymentGatewayType: "Manual" as PaymentGatewayType.Manual, + packageManager: "pnpm", + }, + }, + { + name: "global-services", + args: { + epccClientId: process.env.EPCC_CLIENT_ID, + epccClientSecret: process.env.EPCC_CLIENT_SECRET, + epccEndpointUrl: process.env.EPCC_ENDPOINT, + skipGit: true, + skipInstall: true, + skipConfig: true, + name: "global-services", + dryRun: false, + interactive: false, + plpType: "Klevu" as PlpType.Klevu, + klevuApiKey: process.env.KLEVU_API_KEY, + klevuSearchUrl: process.env.KLEVU_SEARCH_URL, + paymentGatewayType: "Manual" as PaymentGatewayType.Manual, + packageManager: "pnpm", + }, + }, ], }