From b54ccbff430f6cca062c756cbae370bdbc73bba5 Mon Sep 17 00:00:00 2001 From: Robert Field Date: Mon, 27 Nov 2023 17:11:59 +0000 Subject: [PATCH] feat: hooks for account queries with storybook --- .../.storybook/elasticpath-context.tsx | 29 ++ .../react-shopper-hooks/.storybook/main.ts | 32 ++ .../.storybook/preview.tsx | 24 ++ .../mocks/data/fixtures.json | 147 +++++++++ .../react-shopper-hooks/mocks/data/index.ts | 27 ++ .../mocks/handlers/shopper.ts | 23 ++ packages/react-shopper-hooks/mocks/server.ts | 4 + packages/react-shopper-hooks/package.json | 23 +- .../public/mockServiceWorker.js | 303 ++++++++++++++++++ .../src/account/account-provider.tsx | 6 +- .../src/account/hooks/use-account-member.tsx | 8 +- .../hooks/use-authed-account-member.tsx | 38 +++ .../react-shopper-hooks/src/account/index.ts | 1 + .../src/account/login-account.ts | 112 +++++++ .../src/elasticpath/index.ts | 1 + packages/react-shopper-hooks/src/index.ts | 1 + .../src/stories/AccountMember.stories.tsx | 60 ++++ .../stories/AuthedAccountMember.stories.tsx | 58 ++++ .../src/stories/Configure.mdx | 172 ++++++++++ .../src/stories/components/Layout.tsx | 16 + packages/react-shopper-hooks/tsconfig.json | 2 +- .../react-shopper-hooks/tsconfig.mock.json | 25 ++ 22 files changed, 1105 insertions(+), 7 deletions(-) create mode 100644 packages/react-shopper-hooks/.storybook/elasticpath-context.tsx create mode 100644 packages/react-shopper-hooks/.storybook/main.ts create mode 100644 packages/react-shopper-hooks/.storybook/preview.tsx create mode 100644 packages/react-shopper-hooks/mocks/data/fixtures.json create mode 100644 packages/react-shopper-hooks/mocks/data/index.ts create mode 100644 packages/react-shopper-hooks/mocks/handlers/shopper.ts create mode 100644 packages/react-shopper-hooks/mocks/server.ts create mode 100644 packages/react-shopper-hooks/public/mockServiceWorker.js create mode 100644 packages/react-shopper-hooks/src/account/hooks/use-authed-account-member.tsx create mode 100644 packages/react-shopper-hooks/src/account/login-account.ts create mode 100644 packages/react-shopper-hooks/src/elasticpath/index.ts create mode 100644 packages/react-shopper-hooks/src/stories/AccountMember.stories.tsx create mode 100644 packages/react-shopper-hooks/src/stories/AuthedAccountMember.stories.tsx create mode 100644 packages/react-shopper-hooks/src/stories/Configure.mdx create mode 100644 packages/react-shopper-hooks/src/stories/components/Layout.tsx create mode 100644 packages/react-shopper-hooks/tsconfig.mock.json diff --git a/packages/react-shopper-hooks/.storybook/elasticpath-context.tsx b/packages/react-shopper-hooks/.storybook/elasticpath-context.tsx new file mode 100644 index 00000000..7fbac269 --- /dev/null +++ b/packages/react-shopper-hooks/.storybook/elasticpath-context.tsx @@ -0,0 +1,29 @@ +import { QueryClient } from "@tanstack/react-query" +import React from "react" +import { + ElasticPathProvider, + ElasticPathProviderProps, +} from "../src/elasticpath/elasticpath" + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: Infinity, + retry: 1, + }, + }, +}) + +export default function DefaultElasticPathProvider( + props: Omit, +) { + return ( + + ) +} diff --git a/packages/react-shopper-hooks/.storybook/main.ts b/packages/react-shopper-hooks/.storybook/main.ts new file mode 100644 index 00000000..e7b41026 --- /dev/null +++ b/packages/react-shopper-hooks/.storybook/main.ts @@ -0,0 +1,32 @@ +import type { StorybookConfig } from "@storybook/react-vite" +import { join, dirname } from "path" + +/** + * This function is used to resolve the absolute path of a package. + * It is needed in projects that use Yarn PnP or are set up within a monorepo. + */ +function getAbsolutePath(value: string): any { + return dirname(require.resolve(join(value, "package.json"))) +} +const config: StorybookConfig = { + stories: [ + "../src/stories/**/*.mdx", + "../src/stories/**/*.stories.@(js|jsx|mjs|ts|tsx)", + ], + addons: [ + getAbsolutePath("@storybook/addon-links"), + getAbsolutePath("@storybook/addon-essentials"), + getAbsolutePath("@storybook/addon-interactions"), + ], + framework: { + name: getAbsolutePath("@storybook/react-vite"), + options: {}, + }, + docs: { + autodocs: "tag", + }, + typescript: { + check: true, + }, +} +export default config diff --git a/packages/react-shopper-hooks/.storybook/preview.tsx b/packages/react-shopper-hooks/.storybook/preview.tsx new file mode 100644 index 00000000..103e989f --- /dev/null +++ b/packages/react-shopper-hooks/.storybook/preview.tsx @@ -0,0 +1,24 @@ +import type { Preview } from "@storybook/react" +import DefaultElasticPathProvider from "./elasticpath-context" +import React from "react" + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} + +export default preview diff --git a/packages/react-shopper-hooks/mocks/data/fixtures.json b/packages/react-shopper-hooks/mocks/data/fixtures.json new file mode 100644 index 00000000..315d11d4 --- /dev/null +++ b/packages/react-shopper-hooks/mocks/data/fixtures.json @@ -0,0 +1,147 @@ +{ + "resources": { + "cart": { + "data": [ + { + "id": "2eda9f25-903e-4c22-8c42-0c04fbdf94ef", + "type": "cart_item", + "product_id": "ec02203d-34a3-43d6-8530-c98ebce55e67", + "name": "Simple T-Shirt - Blue", + "description": "The simple t-shirt is a light, breathable, and easy to wear t-shirt. It is made of a soft cotton blend and has a ribbed crew neck. The simple t-shirt is perfect for layering or wearing alone. It can be worn with any bottoms and can be easily dressed up or down...", + "sku": "TSHIRT-01BlueSMLong", + "slug": "simple-t-shirtBlueSMLong", + "image": { + "mime_type": "image/jpeg", + "file_name": "womens-tshirts.jpeg", + "href": "https://files-eu.epusercontent.com/856eeae6-45ea-453f-ab75-e53e84bf3c61/1fa7be8b-bdcf-43a0-8748-33e549d2c03e.jpeg" + }, + "quantity": 1, + "manage_stock": false, + "unit_price": { + "amount": 1600, + "currency": "USD", + "includes_tax": false + }, + "value": { + "amount": 1600, + "currency": "USD", + "includes_tax": false + }, + "links": { + "product": "https://api.moltin.com/v2/products/ec02203d-34a3-43d6-8530-c98ebce55e67" + }, + "meta": { + "display_price": { + "with_tax": { + "unit": { + "amount": 1600, + "currency": "USD", + "formatted": "$16.00" + }, + "value": { + "amount": 1600, + "currency": "USD", + "formatted": "$16.00" + } + }, + "without_tax": { + "unit": { + "amount": 1600, + "currency": "USD", + "formatted": "$16.00" + }, + "value": { + "amount": 1600, + "currency": "USD", + "formatted": "$16.00" + } + }, + "tax": { + "unit": { + "amount": 0, + "currency": "USD", + "formatted": "$0.00" + }, + "value": { + "amount": 0, + "currency": "USD", + "formatted": "$0.00" + } + }, + "discount": { + "unit": { + "amount": 0, + "currency": "USD", + "formatted": "$0.00" + }, + "value": { + "amount": 0, + "currency": "USD", + "formatted": "$0.00" + } + }, + "without_discount": { + "unit": { + "amount": 1600, + "currency": "USD", + "formatted": "$16.00" + }, + "value": { + "amount": 1600, + "currency": "USD", + "formatted": "$16.00" + } + } + }, + "timestamps": { + "created_at": "2023-10-26T11:01:16Z", + "updated_at": "2023-10-26T11:01:16Z" + } + }, + "catalog_id": "e4c2d061-3712-408d-bc2c-cfebd0bd104f", + "catalog_source": "pim" + } + ], + "meta": { + "display_price": { + "with_tax": { + "amount": 1600, + "currency": "USD", + "formatted": "$16.00" + }, + "without_tax": { + "amount": 1600, + "currency": "USD", + "formatted": "$16.00" + }, + "tax": { + "amount": 0, + "currency": "USD", + "formatted": "$0.00" + }, + "discount": { + "amount": 0, + "currency": "USD", + "formatted": "$0.00" + }, + "without_discount": { + "amount": 1600, + "currency": "USD", + "formatted": "$16.00" + }, + "shipping": { + "amount": 0, + "currency": "USD", + "formatted": "$0.00" + } + }, + "timestamps": { + "created_at": "2023-10-26T10:51:27Z", + "updated_at": "2023-10-26T11:01:16Z", + "expires_at": "2023-11-02T11:01:16Z" + } + }, + "included": {} + } + } +} \ No newline at end of file diff --git a/packages/react-shopper-hooks/mocks/data/index.ts b/packages/react-shopper-hooks/mocks/data/index.ts new file mode 100644 index 00000000..512767f2 --- /dev/null +++ b/packages/react-shopper-hooks/mocks/data/index.ts @@ -0,0 +1,27 @@ +import data from "./fixtures.json" + +const resources = data["resources"] + +type Resources = typeof resources + +type ResourcesWithKey = { + [K in keyof T]: { [_ in Entity]: K } & T[K] +} + +type KeyedResources = ResourcesWithKey<"entity", Resources> + +export const fixtures = { + get( + entity: Entity, + ): Omit { + return resources[entity as string] + }, + list( + entity: Entity, + number = 2, + ): Omit[] { + return Array(number) + .fill(null) + .map((_) => fixtures.get(entity)) + }, +} as const diff --git a/packages/react-shopper-hooks/mocks/handlers/shopper.ts b/packages/react-shopper-hooks/mocks/handlers/shopper.ts new file mode 100644 index 00000000..7c29a79b --- /dev/null +++ b/packages/react-shopper-hooks/mocks/handlers/shopper.ts @@ -0,0 +1,23 @@ +import { rest } from "msw" +import { fixtures } from "../data" + +export const shopperHandlers = [ + rest.post("https://shopper-mock.com/oauth/access_token", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + identifier: "implicit", + token_type: "Bearer", + expires_in: 3600, + expires: 9999999999, + access_token: "mock-access-token-123", + }), + ) + }), + rest.get( + "https://shopper-mock.com/v2/carts/:cartId/items", + (req, res, ctx) => { + return res(ctx.status(200), ctx.json(fixtures.get("cart"))) + }, + ), +] diff --git a/packages/react-shopper-hooks/mocks/server.ts b/packages/react-shopper-hooks/mocks/server.ts new file mode 100644 index 00000000..33a23a8c --- /dev/null +++ b/packages/react-shopper-hooks/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from "msw/node" +import { shopperHandlers } from "./handlers/shopper" + +export const server = setupServer(...shopperHandlers) diff --git a/packages/react-shopper-hooks/package.json b/packages/react-shopper-hooks/package.json index 01662735..d23c9387 100644 --- a/packages/react-shopper-hooks/package.json +++ b/packages/react-shopper-hooks/package.json @@ -5,7 +5,9 @@ "dev": "vite", "build": "tsup", "clean": "rimraf ./dist", - "test": "vitest --run" + "test": "vitest --run", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "type": "module", "types": "./dist/index.d.ts", @@ -30,17 +32,28 @@ ], "devDependencies": { "@moltin/sdk": "^27.0.0", + "@storybook/addon-essentials": "^7.5.3", + "@storybook/addon-interactions": "^7.5.3", + "@storybook/addon-links": "^7.5.3", + "@storybook/blocks": "^7.5.3", + "@storybook/react": "^7.5.3", + "@storybook/react-vite": "^7.5.3", + "@storybook/testing-library": "^0.2.2", "@tanstack/react-query": "^5.8.4", "@types/js-cookie": "^3.0.6", "@types/react": "^18.2.33", "@types/react-dom": "^18.2.14", "@vitejs/plugin-react": "^2.2.0", "esbuild-plugin-file-path-extensions": "^1.0.0", + "msw": "^1.2.1", + "msw-storybook-addon": "^1.10.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-json-view": "^1.21.3", "rimraf": "^5.0.5", + "storybook": "^7.5.3", "tsup": "^8.0.1", - "typescript": "^4.8.4", + "typescript": "^5.3.2", "vite": "^3.2.3", "vite-plugin-dts": "^1.7.0", "vitest": "^0.31.1" @@ -57,6 +70,10 @@ "dependencies": { "@elasticpath/shopper-common": "workspace:^0.2.1", "js-cookie": "^3.0.5", + "jwt-decode": "^3.1.2", "rxjs": "7.5.7" + }, + "msw": { + "workerDirectory": "public" } -} +} \ No newline at end of file diff --git a/packages/react-shopper-hooks/public/mockServiceWorker.js b/packages/react-shopper-hooks/public/mockServiceWorker.js new file mode 100644 index 00000000..51d85eee --- /dev/null +++ b/packages/react-shopper-hooks/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.3.2). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/packages/react-shopper-hooks/src/account/account-provider.tsx b/packages/react-shopper-hooks/src/account/account-provider.tsx index 1063e284..e92f12d1 100644 --- a/packages/react-shopper-hooks/src/account/account-provider.tsx +++ b/packages/react-shopper-hooks/src/account/account-provider.tsx @@ -1,7 +1,9 @@ import React, { createContext, ReactNode } from "react" +import { AccountMember } from "@moltin/sdk" interface AccountState { accountCookieName: string + profile: AccountMember | null } export const AccountProviderContext = createContext(null) @@ -18,7 +20,9 @@ export const AccountProvider = ({ accountCookieName = ACCOUNT_MEMBER_TOKEN_STR, }: AccountProviderProps) => { return ( - + {children} ) diff --git a/packages/react-shopper-hooks/src/account/hooks/use-account-member.tsx b/packages/react-shopper-hooks/src/account/hooks/use-account-member.tsx index e2f744cd..617ba8d9 100644 --- a/packages/react-shopper-hooks/src/account/hooks/use-account-member.tsx +++ b/packages/react-shopper-hooks/src/account/hooks/use-account-member.tsx @@ -3,11 +3,13 @@ import { UseQueryOptionsWrapper } from "../../types" import { AccountMember, Resource } from "@moltin/sdk" import { useQuery } from "@tanstack/react-query" import { queryKeysFactory } from "../../shared/util/query-keys-factory" +import { UseQueryResult } from "@tanstack/react-query/src/types" const ACCOUNT_MEMBER_QUERY_KEY = "account-member" as const export const accountMemberQueryKeys = queryKeysFactory(ACCOUNT_MEMBER_QUERY_KEY) type AccountMemberQueryKey = typeof accountMemberQueryKeys +type Temp = UseQueryResult | undefined, Error> export function useAccountMember( id: string, @@ -16,13 +18,14 @@ export function useAccountMember( Error, ReturnType > & { ep?: { accountMemberToken?: string } }, -) { +): Partial> & + Omit, Error>, "data"> { const { client } = useElasticPath() const { data, ...rest } = useQuery({ queryKey: accountMemberQueryKeys.detail(id), queryFn: () => client.request.send( - `/account-members/${id}`, + `account-members/${id}`, "GET", undefined, undefined, @@ -38,5 +41,6 @@ export function useAccountMember( ), ...options, }) + return { ...data, ...rest } as const } diff --git a/packages/react-shopper-hooks/src/account/hooks/use-authed-account-member.tsx b/packages/react-shopper-hooks/src/account/hooks/use-authed-account-member.tsx new file mode 100644 index 00000000..88e446d2 --- /dev/null +++ b/packages/react-shopper-hooks/src/account/hooks/use-authed-account-member.tsx @@ -0,0 +1,38 @@ +import { useAccountMember } from "./use-account-member" +import { useContext } from "react" +import { AccountProviderContext } from "../account-provider" +import Cookies from "js-cookie" +import { + createCookieTokenStore, + resolveAccountMemberIdFromToken, +} from "../login-account" +import { useElasticPath } from "../../elasticpath/elasticpath" +import { AccountMember, Resource } from "@moltin/sdk" +import { UseQueryResult } from "@tanstack/react-query/src/types" + +export function useAuthedAccountMember(): Partial> & + Omit, Error>, "data"> { + const ctx = useContext(AccountProviderContext) + + if (!ctx) { + throw new Error( + "useAuthedAccountMember must be used within an AccountProvider", + ) + } + + const { client } = useElasticPath() + + const tokenStore = createCookieTokenStore(ctx.accountCookieName) + const authedAccountMemberId = resolveAccountMemberIdFromToken( + client, + tokenStore, + ) + const accountCookie = Cookies.get(ctx.accountCookieName) + + const result = useAccountMember(authedAccountMemberId ?? "", { + enabled: !!accountCookie && !!authedAccountMemberId, + ep: { accountMemberToken: accountCookie }, + }) + + return { ...result } as const +} diff --git a/packages/react-shopper-hooks/src/account/index.ts b/packages/react-shopper-hooks/src/account/index.ts index 5ecbef9e..e8c635f3 100644 --- a/packages/react-shopper-hooks/src/account/index.ts +++ b/packages/react-shopper-hooks/src/account/index.ts @@ -1,2 +1,3 @@ export * from "./hooks/use-account-member" +export * from "./hooks/use-authed-account-member" export * from "./account-provider" diff --git a/packages/react-shopper-hooks/src/account/login-account.ts b/packages/react-shopper-hooks/src/account/login-account.ts new file mode 100644 index 00000000..d1954562 --- /dev/null +++ b/packages/react-shopper-hooks/src/account/login-account.ts @@ -0,0 +1,112 @@ +import { AccountMember, Moltin, Resource } from "@moltin/sdk" +import Cookies from "js-cookie" +import jwtDecode from "jwt-decode" + +export type CookieTokenStore = { + __type: "cookie" + name: string + getToken(): string | undefined + setToken(token: string): void + deleteToken(): void +} + +export type TokenStore = CookieTokenStore + +export function createCookieTokenStore( + name: string = "_store_ep_account_member_token", +): TokenStore { + return { + __type: "cookie", + name, + getToken() { + return Cookies.get(name) + }, + setToken(token: string) { + Cookies.set(name, token) + }, + deleteToken() { + Cookies.remove(name) + }, + } +} + +export async function loginUsernamePassword( + client: Moltin, + details: { + passwordProfileId: string + email: string + password: string + }, +) { + return client.AccountMembers.GenerateAccountToken({ + type: "account_management_authentication_token", + authentication_mechanism: "password", + password_profile_id: details.passwordProfileId, + username: details.email.toLowerCase(), // Known bug for uppercase usernames so we force lowercase. + password: details.password, + }) +} + +export async function resolveAccountMember( + client: Moltin, + tokenStore: TokenStore, +) { + const token = tokenStore.getToken() + + if (!token) { + return undefined + } + + const decodedToken = token ? jwtDecode<{ sub?: string }>(token) : undefined + + if (!decodedToken) { + return undefined + } + + const { sub: accountMemberId } = decodedToken + + if (!accountMemberId) { + return undefined + } + + try { + const result: Resource = await client.request.send( + `account_members/${accountMemberId}`, + "GET", + undefined, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": token, + }, + ) + + return result + } catch (error) { + console.error(error) + return undefined + } +} + +export function resolveAccountMemberIdFromToken( + client: Moltin, + tokenStore: TokenStore, +) { + const token = tokenStore.getToken() + + if (!token) { + return undefined + } + + const decodedToken = token ? jwtDecode<{ sub?: string }>(token) : undefined + + if (!decodedToken) { + return undefined + } + + const { sub: accountMemberId } = decodedToken + + return accountMemberId +} diff --git a/packages/react-shopper-hooks/src/elasticpath/index.ts b/packages/react-shopper-hooks/src/elasticpath/index.ts new file mode 100644 index 00000000..697aa7b3 --- /dev/null +++ b/packages/react-shopper-hooks/src/elasticpath/index.ts @@ -0,0 +1 @@ +export * from "./elasticpath" diff --git a/packages/react-shopper-hooks/src/index.ts b/packages/react-shopper-hooks/src/index.ts index 8e24e862..1c9bb9ce 100644 --- a/packages/react-shopper-hooks/src/index.ts +++ b/packages/react-shopper-hooks/src/index.ts @@ -5,4 +5,5 @@ export * from "./payment-gateway-register" export * from "./store" export * from "./product" export * from "./account" +export * from "./elasticpath" export * from "@elasticpath/shopper-common" diff --git a/packages/react-shopper-hooks/src/stories/AccountMember.stories.tsx b/packages/react-shopper-hooks/src/stories/AccountMember.stories.tsx new file mode 100644 index 00000000..d0e6c61a --- /dev/null +++ b/packages/react-shopper-hooks/src/stories/AccountMember.stories.tsx @@ -0,0 +1,60 @@ +import { Meta } from "@storybook/react" +import React from "react" + +import Layout from "./components/Layout" +import { useAccountMember } from "../account" + +const AccountMember = ({ + showHookData, + id, +}: { + showHookData: boolean + id: string +}) => { + const { data, isLoading } = useAccountMember(id, { + ep: { + accountMemberToken: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50X21lbWJlcl9zZWxmX21hbmFnZW1lbnQiOiJ1cGRhdGVfb25seSIsImFuY2VzdG9ycyI6IiIsImV4cCI6MTcwMDgzNTkwNSwiaWF0IjoxNzAwNzQ5NTA1LCJyZWFsbV9pZCI6IjYxZmM5MTQ4LWI2YTYtNGI5MS04NDEyLWFlNDM0MDE0M2Q4OCIsInNjb3BlIjoiNmY0ODkzMTQtZTY1NS00ZTk5LWFjNzAtYmRkMDU2NzQ2NGRkIiwic3RvcmVfaWQiOiI4NTZlZWFlNi00NWVhLTQ1M2YtYWI3NS1lNTNlODRiZjNjNjEiLCJzdWIiOiIwY2ZjZjBkZS1kZDRmLTRiYjYtYWNjOC0wOTllMWMwZjVlMGMifQ.EQ25Sd29GOzyJnOQ_ELT7IfxhBvj6V0zpZL1r_3ol3M", + }, + }) // TODO add real token + return ( + +

Account Member: {id}

+

{data?.name}

+
+ ) +} + +const meta: Meta = { + title: "AccountMember", + argTypes: { + showHookData: { + name: "Show hook data", + description: + "Whether or not story should display JSON of data returned from hook", + control: { + type: "boolean", + }, + defaultValue: true, + }, + }, + parameters: { + controls: { expanded: true }, + }, +} + +export default meta + +export const GetOne = (args: { showHookData: boolean; id: string }) => ( + +) + +GetOne.argTypes = { + id: { + control: { + type: "text", + }, + name: "account member id", + defaultValue: "0cfcf0de-dd4f-4bb6-acc8-099e1c0f5e0c", + }, +} diff --git a/packages/react-shopper-hooks/src/stories/AuthedAccountMember.stories.tsx b/packages/react-shopper-hooks/src/stories/AuthedAccountMember.stories.tsx new file mode 100644 index 00000000..3688cf4d --- /dev/null +++ b/packages/react-shopper-hooks/src/stories/AuthedAccountMember.stories.tsx @@ -0,0 +1,58 @@ +import { Meta } from "@storybook/react" +import React from "react" + +import Layout from "./components/Layout" +import { useAuthedAccountMember } from "../account/hooks/use-authed-account-member" +import { AccountProvider } from "../account" + +const AuthedAccountMember = ({ + showHookData, + id, +}: { + showHookData: boolean + id: string +}) => { + const data = useAuthedAccountMember() + return ( + +

Account Member: {id}

+

{data?.data?.name}

+
+ ) +} + +const meta: Meta = { + title: "AuthedAccountMember", + argTypes: { + showHookData: { + name: "Show hook data", + description: + "Whether or not story should display JSON of data returned from hook", + control: { + type: "boolean", + }, + defaultValue: true, + }, + }, + parameters: { + controls: { expanded: true }, + }, +} + +export default meta + +export const GetOne = (args: { showHookData: boolean; id: string }) => ( + + + +) + +GetOne.argTypes = { + id: { + control: { + type: "text", + }, + name: "account member id", + defaultValue: "0cfcf0de-dd4f-4bb6-acc8-099e1c0f5e0c", + }, +} diff --git a/packages/react-shopper-hooks/src/stories/Configure.mdx b/packages/react-shopper-hooks/src/stories/Configure.mdx new file mode 100644 index 00000000..171c4287 --- /dev/null +++ b/packages/react-shopper-hooks/src/stories/Configure.mdx @@ -0,0 +1,172 @@ +import { Meta } from "@storybook/blocks"; + + + +
+
+ # Elastic Path Shopper Hooks + +

+ Elastic Path Shopper Hooks are a set of React hooks that allow you to + easily integrate Elastic Path into your React storefront application. +

+
+
+ + diff --git a/packages/react-shopper-hooks/src/stories/components/Layout.tsx b/packages/react-shopper-hooks/src/stories/components/Layout.tsx new file mode 100644 index 00000000..314ea5b8 --- /dev/null +++ b/packages/react-shopper-hooks/src/stories/components/Layout.tsx @@ -0,0 +1,16 @@ +import React from "react" +import ReactJson from "react-json-view" + +const Layout = ({ children, showHookData, data }: any) => { + return ( +
+
{children}
+
+

Raw

+ {showHookData && } +
+
+ ) +} + +export default Layout diff --git a/packages/react-shopper-hooks/tsconfig.json b/packages/react-shopper-hooks/tsconfig.json index b4c49e79..8126eff5 100644 --- a/packages/react-shopper-hooks/tsconfig.json +++ b/packages/react-shopper-hooks/tsconfig.json @@ -22,5 +22,5 @@ "lib": ["dom", "dom.iterable", "esnext"], }, "include": [ - "src/**/*", "example/**/*"] + "src/**/*", "example/**/*", ".storybook/**/*"] } \ No newline at end of file diff --git a/packages/react-shopper-hooks/tsconfig.mock.json b/packages/react-shopper-hooks/tsconfig.mock.json new file mode 100644 index 00000000..855d3b1e --- /dev/null +++ b/packages/react-shopper-hooks/tsconfig.mock.json @@ -0,0 +1,25 @@ +{ + // test comment + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "importHelpers": true, + "outDir": "dist", + "strict": true, + "jsx": "react", + "declaration": true, + "declarationMap": true, + "allowJs": true, + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": [ + "vite/client" + ], + "baseUrl": ".", + "lib": ["dom", "dom.iterable", "esnext"], + }, + "include": ["mocks/**/*"] +} \ No newline at end of file