diff --git a/packages/component-boilerplate/package-lock.json b/packages/component-boilerplate/package-lock.json index 07b3185b6b..492bd5e442 100644 --- a/packages/component-boilerplate/package-lock.json +++ b/packages/component-boilerplate/package-lock.json @@ -1,19 +1,18 @@ { "name": "@saasquatch/component-boilerplate", - "version": "1.6.9", + "version": "1.6.10-2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@saasquatch/component-boilerplate", - "version": "1.6.9", + "version": "1.6.10-2", "license": "MIT", "dependencies": { "@formatjs/intl": "^1.8.2", "@saasquatch/component-environment": "^1.0.10", - "@saasquatch/dom-context-hooks": "^1.0.5", - "@saasquatch/stencil-hooks": "^2.0.2", - "@saasquatch/universal-hooks": "^1.0.0", + "@saasquatch/dom-context-hooks": "^1.0.4-0", + "@saasquatch/universal-hooks": "^1.0.1", "@wry/equality": "^0.5.2", "debounce": "^1.2.0", "dom-context": "^1.3.1", @@ -21,7 +20,7 @@ "fast-memoize": "^2.5.2", "graphql": "^15.5.0", "graphql-combine-query": "^1.2.3", - "graphql-request": "^3.4.0", + "graphql-request": "3.4.0", "jsonpointer": "^4.1.0", "jwt-decode": "^3.1.2", "nanoid": "^3.1.23", @@ -3109,30 +3108,18 @@ } }, "node_modules/@saasquatch/dom-context-hooks": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@saasquatch/dom-context-hooks/-/dom-context-hooks-1.0.5.tgz", - "integrity": "sha512-YV98E6MFX3eb6bIuUS31+SlNDvcxGXUqfwuoIdBB95AAnbxzv/OHMcaaLZxExj7MpqrDU6pEPLqyIvZO9J4opg==", - "dependencies": { - "@saasquatch/universal-hooks": "^1.0.0", - "dom-context": "^1.3.1" - } - }, - "node_modules/@saasquatch/stencil-hooks": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@saasquatch/stencil-hooks/-/stencil-hooks-2.0.2.tgz", - "integrity": "sha512-NKbhkwhWALttnS0K1smWjfwkfU0CqmzRxbhl7a2YjjddSUljWYHOb5iKzDdZXLDtz6/0T+BA2uQiPDsnQ7x6MA==", + "version": "1.0.4-0", + "resolved": "https://registry.npmjs.org/@saasquatch/dom-context-hooks/-/dom-context-hooks-1.0.4-0.tgz", + "integrity": "sha512-EQN8AXJVz+yiK4Al9dFADB4xpivd9e0p0mXBwf+UkbYczqRcTdhGjs/IQmT1CCmRdQOgN0DiQoFH2hRfh3Tgfw==", "dependencies": { - "@saasquatch/dom-context-hooks": "^1.0.0", - "@stencil/core": "^2", - "debug": "^4.3.1", - "dom-context": "^1.2.0", - "haunted": "^4.7.1" + "@saasquatch/universal-hooks": "^1.0.1", + "dom-context": "^1.2.0" } }, "node_modules/@saasquatch/universal-hooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@saasquatch/universal-hooks/-/universal-hooks-1.0.0.tgz", - "integrity": "sha512-afvknW4VA/1L1gbqBXJg3JCRzE4Ud8OkhCl1noGKt/cKa/Ec+G8OCBFAOiTxdGVSZkVQ2iqhTam+ugDY9sQUuQ==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@saasquatch/universal-hooks/-/universal-hooks-1.0.1.tgz", + "integrity": "sha512-jxyaBJzvc3TsGQjWvXtfeoOblrSMPzsZlnEdjHPqbAfWL3eIsgTAXHP79L+OKz1TAFrXU+LmLokvgGgZPocwcQ==" }, "node_modules/@saasquatch/universal-hooks-testing-library": { "version": "0.1.0", @@ -3158,18 +3145,6 @@ "@sinonjs/commons": "^1.7.0" } }, - "node_modules/@stencil/core": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.5.2.tgz", - "integrity": "sha512-bgjPXkSzzg1WnTgVUm6m5ZzpKt602WmA/QljODAW1xVN40OHJdbGblzF/F6MFzqv2c5Cy30CB41arc8qADIdcQ==", - "bin": { - "stencil": "bin/stencil" - }, - "engines": { - "node": ">=12.10.0", - "npm": ">=6.0.0" - } - }, "node_modules/@testing-library/react-hooks": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-5.1.0.tgz", @@ -5397,6 +5372,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -7283,14 +7259,6 @@ "node": ">=0.10.0" } }, - "node_modules/haunted": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/haunted/-/haunted-4.8.0.tgz", - "integrity": "sha512-hLlUojolbdSLkC0y1kB324TulzUS4Ie7yNqmcYgv0pYtsFTqiBRgJ0+oqwMCIex52HkgcsgxFPKLUAzR6FQWWw==", - "dependencies": { - "lit-html": "^1.0.0" - } - }, "node_modules/headers-utils": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/headers-utils/-/headers-utils-3.0.2.tgz", @@ -10167,11 +10135,6 @@ "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", "dev": true }, - "node_modules/lit-html": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz", - "integrity": "sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==" - }, "node_modules/loader-utils": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", @@ -10632,7 +10595,8 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true }, "node_modules/msw": { "version": "0.28.1", @@ -18898,30 +18862,18 @@ } }, "@saasquatch/dom-context-hooks": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@saasquatch/dom-context-hooks/-/dom-context-hooks-1.0.5.tgz", - "integrity": "sha512-YV98E6MFX3eb6bIuUS31+SlNDvcxGXUqfwuoIdBB95AAnbxzv/OHMcaaLZxExj7MpqrDU6pEPLqyIvZO9J4opg==", + "version": "1.0.4-0", + "resolved": "https://registry.npmjs.org/@saasquatch/dom-context-hooks/-/dom-context-hooks-1.0.4-0.tgz", + "integrity": "sha512-EQN8AXJVz+yiK4Al9dFADB4xpivd9e0p0mXBwf+UkbYczqRcTdhGjs/IQmT1CCmRdQOgN0DiQoFH2hRfh3Tgfw==", "requires": { - "@saasquatch/universal-hooks": "^1.0.0", - "dom-context": "^1.3.1" - } - }, - "@saasquatch/stencil-hooks": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@saasquatch/stencil-hooks/-/stencil-hooks-2.0.2.tgz", - "integrity": "sha512-NKbhkwhWALttnS0K1smWjfwkfU0CqmzRxbhl7a2YjjddSUljWYHOb5iKzDdZXLDtz6/0T+BA2uQiPDsnQ7x6MA==", - "requires": { - "@saasquatch/dom-context-hooks": "^1.0.0", - "@stencil/core": "^2", - "debug": "^4.3.1", - "dom-context": "^1.2.0", - "haunted": "^4.7.1" + "@saasquatch/universal-hooks": "^1.0.1", + "dom-context": "^1.2.0" } }, "@saasquatch/universal-hooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@saasquatch/universal-hooks/-/universal-hooks-1.0.0.tgz", - "integrity": "sha512-afvknW4VA/1L1gbqBXJg3JCRzE4Ud8OkhCl1noGKt/cKa/Ec+G8OCBFAOiTxdGVSZkVQ2iqhTam+ugDY9sQUuQ==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@saasquatch/universal-hooks/-/universal-hooks-1.0.1.tgz", + "integrity": "sha512-jxyaBJzvc3TsGQjWvXtfeoOblrSMPzsZlnEdjHPqbAfWL3eIsgTAXHP79L+OKz1TAFrXU+LmLokvgGgZPocwcQ==" }, "@saasquatch/universal-hooks-testing-library": { "version": "0.1.0", @@ -18947,11 +18899,6 @@ "@sinonjs/commons": "^1.7.0" } }, - "@stencil/core": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.5.2.tgz", - "integrity": "sha512-bgjPXkSzzg1WnTgVUm6m5ZzpKt602WmA/QljODAW1xVN40OHJdbGblzF/F6MFzqv2c5Cy30CB41arc8qADIdcQ==" - }, "@testing-library/react-hooks": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-5.1.0.tgz", @@ -20756,6 +20703,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, "requires": { "ms": "2.1.2" } @@ -22111,14 +22059,6 @@ } } }, - "haunted": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/haunted/-/haunted-4.8.0.tgz", - "integrity": "sha512-hLlUojolbdSLkC0y1kB324TulzUS4Ie7yNqmcYgv0pYtsFTqiBRgJ0+oqwMCIex52HkgcsgxFPKLUAzR6FQWWw==", - "requires": { - "lit-html": "^1.0.0" - } - }, "headers-utils": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/headers-utils/-/headers-utils-3.0.2.tgz", @@ -24303,11 +24243,6 @@ "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", "dev": true }, - "lit-html": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz", - "integrity": "sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==" - }, "loader-utils": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", @@ -24682,7 +24617,8 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true }, "msw": { "version": "0.28.1", diff --git a/packages/component-boilerplate/package.json b/packages/component-boilerplate/package.json index b5bf05415c..47a14f76ff 100644 --- a/packages/component-boilerplate/package.json +++ b/packages/component-boilerplate/package.json @@ -1,6 +1,6 @@ { "name": "@saasquatch/component-boilerplate", - "version": "1.6.9", + "version": "1.6.10-3", "private": false, "description": "Boilerplate for writing web components for the SaaSquatch widget environments", "source": "src/index.ts", @@ -41,9 +41,8 @@ "dependencies": { "@formatjs/intl": "^1.8.2", "@saasquatch/component-environment": "^1.0.10", - "@saasquatch/dom-context-hooks": "^1.0.5", - "@saasquatch/stencil-hooks": "^2.0.2", - "@saasquatch/universal-hooks": "^1.0.0", + "@saasquatch/dom-context-hooks": "^1.0.4-0", + "@saasquatch/universal-hooks": "^1.0.1", "@wry/equality": "^0.5.2", "debounce": "^1.2.0", "dom-context": "^1.3.1", @@ -51,7 +50,7 @@ "fast-memoize": "^2.5.2", "graphql": "^15.5.0", "graphql-combine-query": "^1.2.3", - "graphql-request": "^3.4.0", + "graphql-request": "3.4.0", "jsonpointer": "^4.1.0", "jwt-decode": "^3.1.2", "nanoid": "^3.1.23", diff --git a/packages/component-boilerplate/src/hooks/graphql/useParentQuery.ts b/packages/component-boilerplate/src/hooks/graphql/useParentQuery.ts deleted file mode 100644 index 28a0565fc9..0000000000 --- a/packages/component-boilerplate/src/hooks/graphql/useParentQuery.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useDomContextState } from "@saasquatch/dom-context-hooks"; -import { useDomContext } from "@saasquatch/stencil-hooks"; -import { useEffect } from "@saasquatch/universal-hooks"; -import { deepEqual } from "fast-equals"; -import { getContextValueName } from "../useParentState"; -import { useHost } from "../useHost"; -import { useQuery } from "./useQuery"; -import { QueryData } from "./useBaseQuery"; - -type ParentQueryProps = { - namespace: string; - query?: string; - variables?: { [key: string]: unknown }; - skip?: boolean; - options?: { batch?: boolean }; -}; - -export function useParentQuery(props: ParentQueryProps): QueryData { - const host = useHost(); - const query = useQuery(props.query, props.variables || {}, props.skip, { - batch: true, - ...(props.options || {}), - }); - - const [value, setValue] = useDomContextState>( - host, - getContextValueName(props.namespace), - query - ); - - useEffect(() => { - if (deepEqual(query, value)) return; - setValue(query); - }, [query, value]); - - return query; -} - -export function useParentQueryValue(namespace: string): QueryData { - const parentValue = useDomContext | undefined>( - getContextValueName(namespace) - ); - - if (!parentValue) - return { - data: null, - loading: true, - refetch: () => console.warn("component still loading"), - } as QueryData; - return parentValue; -} diff --git a/packages/component-boilerplate/src/hooks/useParentState.ts b/packages/component-boilerplate/src/hooks/useParentState.ts deleted file mode 100644 index ca7d7c50bf..0000000000 --- a/packages/component-boilerplate/src/hooks/useParentState.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useDomContextState } from "@saasquatch/dom-context-hooks"; -import { useDomContext } from "@saasquatch/stencil-hooks"; -import { useHost } from "./useHost"; - -export type StateType = [value?: T, setValue?: (value: T) => void]; - -type ParentStateProps = { - namespace: string; - initialValue?: T; -}; - -export function useParentState(props: ParentStateProps): StateType { - const host = useHost(); - const [value, setValue] = useDomContextState( - host, - getContextValueName(props.namespace), - props.initialValue as T - ); - - useDomContextState>(host, getContextName(props.namespace), [ - value, - setValue, - ]); - - return [value, setValue]; -} - -export function useParent(namespace: string): StateType { - const parent = useDomContext>( - getContextName(namespace) - ); - - const parentValue = useDomContext( - getContextValueName(namespace) - ); - - if (!parent) return [undefined, undefined]; - return [parentValue, parent[1]]; -} - -export function useSetParent( - namespace: string -): (value: T) => void | undefined { - const parent = useDomContext>( - getContextName(namespace) - ); - if (!parent) return undefined; - return parent[1]; -} - -export function useParentValue(namespace: string): T | undefined { - const parentValue = useDomContext( - getContextValueName(namespace) - ); - - if (!parentValue) undefined; - return parentValue; -} - -export function getContextValueName(namespace: string) { - return namespace + "-value"; -} - -export function getContextName(namespace: string) { - return namespace + "-state"; -} diff --git a/packages/component-boilerplate/src/index.ts b/packages/component-boilerplate/src/index.ts index 7a9b7870cd..5a73462fab 100644 --- a/packages/component-boilerplate/src/index.ts +++ b/packages/component-boilerplate/src/index.ts @@ -31,10 +31,6 @@ export { GRAPHQL_CONTEXT, } from "./hooks/graphql/useGraphQLClient"; export { useRefreshDispatcher } from "./hooks/graphql/Refresh"; -export { - useParentQuery, - useParentQueryValue, -} from "./hooks/graphql/useParentQuery"; // Navigation export { navigation, useCurrentPage } from "./hooks/useNavigation"; @@ -54,14 +50,6 @@ export { useHost, setImplementation as setUseHostImplementation, } from "./hooks/useHost"; -export { - useParentState, - useParent, - useParentValue, - useSetParent, - getContextName, - getContextValueName, -} from "./hooks/useParentState"; // // Common higher-level functionality diff --git a/packages/lit-components/.eslintrc.json b/packages/lit-components/.eslintrc.json new file mode 100644 index 0000000000..aca0e6f0b4 --- /dev/null +++ b/packages/lit-components/.eslintrc.json @@ -0,0 +1,28 @@ +{ + "env": { + "browser": true, + "es2021": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:storybook/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_" + } + ] + } +} \ No newline at end of file diff --git a/packages/lit-components/.gitignore b/packages/lit-components/.gitignore new file mode 100644 index 0000000000..ca77958e02 --- /dev/null +++ b/packages/lit-components/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +.DS_Store +*.log +.vscode/ +.idea/ +*.swp +*.swo +*~ + +*storybook.log +storybook-static diff --git a/packages/lit-components/.npmignore b/packages/lit-components/.npmignore new file mode 100644 index 0000000000..d160cf769f --- /dev/null +++ b/packages/lit-components/.npmignore @@ -0,0 +1,15 @@ +src/ +.eslintrc.json +.prettierrc +vite.config.ts +tsconfig.json +*.log +node_modules/ +.git/ +.github/ +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store diff --git a/packages/lit-components/.prettierrc b/packages/lit-components/.prettierrc new file mode 100644 index 0000000000..c7a55c7409 --- /dev/null +++ b/packages/lit-components/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/packages/lit-components/.storybook/main.ts b/packages/lit-components/.storybook/main.ts new file mode 100644 index 0000000000..64392773f9 --- /dev/null +++ b/packages/lit-components/.storybook/main.ts @@ -0,0 +1,16 @@ +import type { StorybookConfig } from '@storybook/web-components-vite'; + +const config: StorybookConfig = { + "stories": [ + "../src/**/*.mdx", + "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" + ], + "addons": [ + "@chromatic-com/storybook", + "@storybook/addon-vitest", + "@storybook/addon-a11y", + "@storybook/addon-docs" + ], + "framework": "@storybook/web-components-vite" +}; +export default config; \ No newline at end of file diff --git a/packages/lit-components/.storybook/preview.ts b/packages/lit-components/.storybook/preview.ts new file mode 100644 index 0000000000..68736ba739 --- /dev/null +++ b/packages/lit-components/.storybook/preview.ts @@ -0,0 +1,21 @@ +import type { Preview } from '@storybook/web-components-vite'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + }, + }, +}; + +export default preview; diff --git a/packages/lit-components/.storybook/vitest.setup.ts b/packages/lit-components/.storybook/vitest.setup.ts new file mode 100644 index 0000000000..2965ba897f --- /dev/null +++ b/packages/lit-components/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; +import { setProjectAnnotations } from '@storybook/web-components-vite'; +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); \ No newline at end of file diff --git a/packages/lit-components/CSS_TOKEN_MIGRATION_PLAN.md b/packages/lit-components/CSS_TOKEN_MIGRATION_PLAN.md new file mode 100644 index 0000000000..b20ded44e5 --- /dev/null +++ b/packages/lit-components/CSS_TOKEN_MIGRATION_PLAN.md @@ -0,0 +1,206 @@ +# CSS Token Migration Plan — `@saasquatch/lit-components` + +> Plan for the day we swap out the underlying UI primitive framework +> (Shoelace today) and therefore lose the `--sl-*` CSS custom properties. +> Scope is **CSS tokens only**: the component markup is already abstracted +> behind the `UI` adapter (`src/ui/registry.ts`, `src/ui/types.ts`), so +> swapping markup is a separate, already-solved concern. + +--- + +## The problem in one paragraph + +Component views (`src/components/**/*View.ts`) and several stories +reference Shoelace's design tokens directly — e.g. +`var(--sl-spacing-medium)`, `var(--sl-color-neutral-200)`, +`var(--sl-border-radius-medium)`, `var(--sl-font-size-large)`. These +tokens are provided at runtime by +`@shoelace-style/shoelace/dist/themes/light.css`, loaded once via +`src/ui/shoelace/bootstrap.ts`. The moment we stop loading Shoelace's +theme, every `var(--sl-*)` resolves to its (often missing) fallback and +the UI breaks visually. There are ~**396 `--sl-*` references** across +~**90 files**, drawing from ~**64 unique tokens** in five families: +spacing, color (neutral/primary/success/warning/danger scales), +border-radius, font-size, font-weight, shadow, and one stray +`--sl-input-color`. + +--- + +## Goal + +Decouple the codebase from the `--sl-*` namespace so a future UI +framework swap is a one-file change to the token mappings, not a +sweep of every view. + +--- + +## Strategy: introduce a `--sq-*` token layer + +Stop referencing `--sl-*` directly from component code. Instead: + +1. Define a **`--sq-*` design-token namespace** owned by this package + (e.g. `--sq-spacing-md`, `--sq-color-neutral-200`, + `--sq-radius-md`, `--sq-font-size-lg`, `--sq-font-weight-bold`, + `--sq-shadow-sm`). These names express _intent_ in our design + language, not Shoelace's. +2. Ship a single **token stylesheet per UI adapter** that maps + `--sq-*` to the underlying framework's tokens. For Shoelace today + that file is one-liners like + `--sq-spacing-md: var(--sl-spacing-medium);`. For a future + framework it becomes `--sq-spacing-md: var(--md-sys-spacing-3);` + or hard-coded values, with no view edits required. +3. Migrate every `var(--sl-*)` occurrence in `src/components/**` to + the `--sq-*` equivalent. +4. From then on, "swap the UI framework" = write a new adapter under + `src/ui//` (already the convention per `src/ui/registry.ts`) + plus a new token stylesheet, and update the bootstrap import. + +This mirrors what the existing UI adapter already does for markup +(`UIComponents` interface) — we're just extending the same +abstraction to the CSS layer. + +--- + +## Inventory (snapshot at time of writing) + +Unique tokens referenced from `src/`: + +- **Spacing** (8): `--sl-spacing-2x-small`, `--sl-spacing-x-small`, + `--sl-spacing-small`, `--sl-spacing-medium`, `--sl-spacing-large`, + `--sl-spacing-x-large`, plus bare prefix matches. +- **Color – neutral** (10): `--sl-color-neutral-0`, `-50`, `-100`, + `-200`, `-300`, `-400`, `-500`, `-600`, `-700`. +- **Color – primary** (6): `--sl-color-primary-50`, `-100`, `-300`, + `-500`, `-600`, `-700`. +- **Color – success** (6): `-50`, `-100`, `-300`, `-500`, `-600`, `-700`. +- **Color – warning** (2): `-100`, `-700`. +- **Color – danger** (1): `--sl-color-danger-600` (used inside + `src/ui/shoelace/index.ts` for the form-field error slot). +- **Border-radius** (2): `--sl-border-radius-medium`, + `--sl-border-radius-large`. +- **Font-size** (7): `x-small`, `small`, `medium`, `large`, + `x-large`, `xx-large`, `xxx-large`. +- **Font-weight** (3): `normal`, `semibold`, `bold`. +- **Shadow** (2): `--sl-shadow-small`, `--sl-shadow-x-large`. +- **Misc** (1): `--sl-input-color`. + +Generate a fresh inventory before starting with: + +```sh +cd packages/lit-components +grep -rhoE -- '--sl-[a-z0-9-]+' src | sort -u +grep -rE 'var\(--sl-' src | wc -l +``` + +--- + +## Migration steps + +### Step 1 — Define the `--sq-*` token set + +- [ ] Create `src/ui/tokens/tokens.css` defining every `--sq-*` the + codebase will need (one entry per item in the inventory above). + Pick stable, framework-neutral names — + `--sq-spacing-md`, `--sq-color-neutral-200`, `--sq-radius-md`, + `--sq-font-size-lg`, etc. +- [ ] Create `src/ui/tokens/types.ts` exporting a TypeScript + string-literal union (`type SqToken = '--sq-spacing-md' | ...`) for + any code that needs to reference tokens programmatically. (Optional; + most tokens are used in static CSS strings.) +- [ ] Decide the scope selector. Two viable options: + - `:root, :host` (covers light DOM consumers + every component's + shadow root) — recommended. + - `:root` only, plus per-component `:host { ... }` re-declaration — + only if a downstream consumer needs to override on a per-host + basis. + +### Step 2 — Add the Shoelace mapping layer + +- [ ] Create `src/ui/shoelace/tokens.css` containing the + Shoelace→`--sq-*` mappings, e.g. + + ```css + :root, :host { + --sq-spacing-md: var(--sl-spacing-medium); + --sq-color-neutral-200: var(--sl-color-neutral-200); + --sq-radius-md: var(--sl-border-radius-medium); + /* ...one line per token in the inventory... */ + } + ``` +- [ ] Update `src/ui/shoelace/bootstrap.ts` to also import this file + (alongside the existing `@shoelace-style/shoelace/.../light.css`). + The Shoelace theme must load first so the right-hand side resolves. +- [ ] Verify the mapping covers every token in the inventory — fail + loudly during code review if `tokens.css` lists fewer keys than + `grep -rhoE -- '--sq-[a-z0-9-]+' src | sort -u`. + +### Step 3 — Codemod the references in `src/components/**` + +- [ ] Build a one-shot mapping table (Shoelace token → `--sq-*` name) + derived from Step 1. +- [ ] Run a search-and-replace across `src/components/**/*.ts` + (views, stories, any inline ` + + + + + + +

🚀 @saasquatch/lit-components

+

Web components built with Lit and Haunted for state management

+ +

Activity

+ + +

Referrals

+
+ +

Rewards Earned

+
+
+ + + + + + +
+ +
+ + + + diff --git a/packages/lit-components/package-lock.json b/packages/lit-components/package-lock.json new file mode 100644 index 0000000000..a6aeb326e2 --- /dev/null +++ b/packages/lit-components/package-lock.json @@ -0,0 +1,11651 @@ +{ + "name": "@saasquatch/lit-components", + "version": "0.0.0-7", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@saasquatch/lit-components", + "version": "0.0.0-7", + "license": "MIT", + "dependencies": { + "@raisins/schema": "^1.2.0", + "@saasquatch/component-boilerplate": "^1.6.10-3", + "@saasquatch/universal-hooks": "^1.0.1", + "@shoelace-style/shoelace": "^2.20.1", + "haunted": "^6.1.0", + "lit": "^3.0.0" + }, + "devDependencies": { + "@chromatic-com/storybook": "^4.1.3", + "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@storybook/addon-a11y": "^10.1.0", + "@storybook/addon-docs": "^10.1.0", + "@storybook/addon-vitest": "^10.1.0", + "@storybook/web-components-vite": "^10.1.0", + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^7.16.0", + "@typescript-eslint/parser": "^7.16.0", + "@vitest/browser-playwright": "^4.0.14", + "@vitest/coverage-v8": "^4.0.14", + "eslint": "^8.57.0", + "eslint-plugin-storybook": "^10.1.0", + "msw": "^2.4.9", + "playwright": "^1.57.0", + "prettier": "^3.3.2", + "storybook": "^10.1.0", + "tsx": "^4.21.0", + "typescript": "^5.5.3", + "vite": "^5.4.0", + "vitest": "^4.0.14" + }, + "peerDependencies": { + "lit": "^3.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@chromatic-com/storybook": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-4.1.3.tgz", + "integrity": "sha512-hc0HO9GAV9pxqDE6fTVOV5KeLpTiCfV8Jrpk5ogKLiIgeq2C+NPjpt74YnrZTjiK8E19fYcMP+2WY9ZtX7zHmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@neoconfetti/react": "^1.0.0", + "chromatic": "^13.3.3", + "filesize": "^10.0.12", + "jsonfile": "^6.1.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20.0.0", + "yarn": ">=1.22.18" + }, + "peerDependencies": { + "storybook": "^0.0.0-0 || ^9.0.0 || ^9.1.0-0 || ^9.2.0-0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" + } + }, + "node_modules/@chromatic-com/storybook/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@chromatic-com/storybook/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.3.tgz", + "integrity": "sha512-kP/Buv5vVFMAYLHNvvUzr0lwRTU0u2WTy44Tqwku1X3C3lJ5dKqDCYVqA8wL+Y19Bq+MwHgxqd5FZJRCIsLRyQ==", + "dependencies": { + "@formatjs/intl-localematcher": "0.2.24", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz", + "integrity": "sha512-Rg0e76nomkz3vF9IPlKeV+Qynok0r7YZjL6syLz4/urSg0IbjPZCB/iYUMNsYA643gh4mgrX3T7KEIFIxJBQeg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.0.18.tgz", + "integrity": "sha512-vquIzsAJJmZ5jWVH8dEgUKcbG4yu3KqtyPet+q35SW5reLOvblkfeCXTRW2TpIwNXzdVqsJBwjbTiRiSU9JxwQ==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.3", + "@formatjs/icu-skeleton-parser": "1.3.5", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.5.tgz", + "integrity": "sha512-Nhyo2/6kG7ZfgeEfo02sxviOuBcvtzH6SYUharj3DLCDJH3A/4OxkKcmx/2PWGX4bc6iSieh+FA94CsKDxnZBQ==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.3", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/intl-displaynames": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-5.4.2.tgz", + "integrity": "sha512-SLesCDan9NCMqBbHPXMEwqAcPn3tnbQw0sv0rssH1JQDLDUQYwKXL93kz30X3yskTyQS7N+pd47bhoIe3kbXyw==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.3", + "@formatjs/intl-localematcher": "0.2.24", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/intl-listformat": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-6.5.2.tgz", + "integrity": "sha512-/IYagQJkzTvpBlhhaysGYNgM3o72WBg1ZWZcpookkgXEJbINwLP5kVagHxmgxffYKs1CDzQ8rmKHghu2qR/7zw==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.3", + "@formatjs/intl-localematcher": "0.2.24", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.24.tgz", + "integrity": "sha512-K/HRGo6EMnCbhpth/y3u4rW4aXkmQNqRe1L2G+Y5jNr3v0gYhvaucV8WixNju/INAMbPBlbsRBRo/nfjnoOnxQ==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.6.tgz", + "integrity": "sha512-I/INw4sHGlVZ/afZOckpLiDP9SmbMl1g/GCqeHjLw1Afw/0PlRs2tRFgTGWmdI0hoNuWZn3y2iHNmG1vyECyQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.0.tgz", + "integrity": "sha512-USpeB76eqK7yGricDlGAupxWlp4a59qpeZOoNWaxO/nJln7agpJveyNkQ1d5u8YXG6TOqxZtQpKPORQQDrdVsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.0.tgz", + "integrity": "sha512-joR1YS2sI0us+9d0I8ViqFbrRLONO8CFTuyvBX4ZVBSch+VsZiugUABdrhBXXJR1VyEzvpz5SQCix3keETQ58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.6", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^4.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.6.tgz", + "integrity": "sha512-dsZgQtH2t5Q6ah3aPbZbeEZAxsD9qQu0DXf01AltuEfRTm+NoLN6+rLVbr+4edeEbNCp/wBNM6mALRWtsQpfkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.6.tgz", + "integrity": "sha512-J+9tdxOskuYuGjsvGaq00AamhDgjR7anhEW2dP4QdQpFCMPngCeC/bCYWQ5NsMWZRdsy53is7kAHb/+7cwDk2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz", + "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==" + }, + "node_modules/@lit/react": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz", + "integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==", + "peerDependencies": { + "@types/react": "17 || 18 || 19" + } + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.1.tgz", + "integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.4.0" + } + }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@neoconfetti/react": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@neoconfetti/react/-/react-1.0.0.tgz", + "integrity": "sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@raisins/schema": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@raisins/schema/-/schema-1.2.0.tgz", + "integrity": "sha512-4o/9FpHz9PTYdxjjp/s7RJDXtlS6AK1E66f8ZStZdMC7HKFpvXKk3zzh0kPaYn07VeQGn2vnM0lfYMQVBx+QUw==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-alias": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-6.0.0.tgz", + "integrity": "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==", + "dev": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "rollup": ">=4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@saasquatch/component-boilerplate": { + "version": "1.6.10-3", + "resolved": "https://registry.npmjs.org/@saasquatch/component-boilerplate/-/component-boilerplate-1.6.10-3.tgz", + "integrity": "sha512-zCY3oC/lxf1z2ZudfjufyC4Jpjy6gsGttKJGzBJA9SY0yWONGwwMstB1aR1aJuDOLAD0aZsxbWYIhOMYzdcaww==", + "dependencies": { + "@formatjs/intl": "^1.8.2", + "@saasquatch/component-environment": "^1.0.10", + "@saasquatch/dom-context-hooks": "^1.0.4-0", + "@saasquatch/universal-hooks": "^1.0.1", + "@wry/equality": "^0.5.2", + "debounce": "^1.2.0", + "dom-context": "^1.3.1", + "fast-equals": "^5.0.1", + "fast-memoize": "^2.5.2", + "graphql": "^15.5.0", + "graphql-combine-query": "^1.2.3", + "graphql-request": "3.4.0", + "jsonpointer": "^4.1.0", + "jwt-decode": "^3.1.2", + "nanoid": "^3.1.23", + "rxjs": "^6.6.7" + } + }, + "node_modules/@saasquatch/component-boilerplate/node_modules/@formatjs/intl": { + "version": "1.18.5", + "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-1.18.5.tgz", + "integrity": "sha512-5jiC7neCMXMAa/uQU68plY3vxQqtDjiFPkvc6fEuCmNoVZkIya9uMG8uqM2Z0BOX6xg0K1ohHuj+VLHDDx1fEw==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.3", + "@formatjs/fast-memoize": "1.2.1", + "@formatjs/icu-messageformat-parser": "2.0.18", + "@formatjs/intl-displaynames": "5.4.2", + "@formatjs/intl-listformat": "6.5.2", + "intl-messageformat": "9.11.4", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "typescript": "^4.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@saasquatch/component-environment": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@saasquatch/component-environment/-/component-environment-1.0.10.tgz", + "integrity": "sha512-aKammGzDkgTh9oOaKXUQbSh+m7wM66AvkJz+HCtO6YA7X+2ph16itHiQUq7jO/vP7SyfVyrTztUH0/imC1uaXw==", + "dependencies": { + "@wry/equality": "^0.5.2", + "dom-context": "^1.3.1", + "jwt-decode": "^3.1.2" + }, + "engines": { + "node": ">=16.15" + } + }, + "node_modules/@saasquatch/dom-context-hooks": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@saasquatch/dom-context-hooks/-/dom-context-hooks-1.0.5.tgz", + "integrity": "sha512-YV98E6MFX3eb6bIuUS31+SlNDvcxGXUqfwuoIdBB95AAnbxzv/OHMcaaLZxExj7MpqrDU6pEPLqyIvZO9J4opg==", + "dependencies": { + "@saasquatch/universal-hooks": "^1.0.0", + "dom-context": "^1.3.1" + } + }, + "node_modules/@saasquatch/universal-hooks": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@saasquatch/universal-hooks/-/universal-hooks-1.0.1.tgz", + "integrity": "sha512-jxyaBJzvc3TsGQjWvXtfeoOblrSMPzsZlnEdjHPqbAfWL3eIsgTAXHP79L+OKz1TAFrXU+LmLokvgGgZPocwcQ==" + }, + "node_modules/@shoelace-style/animations": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@shoelace-style/animations/-/animations-1.2.0.tgz", + "integrity": "sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/claviska" + } + }, + "node_modules/@shoelace-style/localize": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.1.tgz", + "integrity": "sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA==" + }, + "node_modules/@shoelace-style/shoelace": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/@shoelace-style/shoelace/-/shoelace-2.20.1.tgz", + "integrity": "sha512-FSghU95jZPGbwr/mybVvk66qRZYpx5FkXL+vLNpy1Vp8UsdwSxXjIHE3fsvMbKWTKi9UFfewHTkc5e7jAqRYoQ==", + "dependencies": { + "@ctrl/tinycolor": "^4.1.0", + "@floating-ui/dom": "^1.6.12", + "@lit/react": "^1.0.6", + "@shoelace-style/animations": "^1.2.0", + "@shoelace-style/localize": "^3.2.1", + "composed-offset-position": "^0.0.6", + "lit": "^3.2.1", + "qr-creator": "^1.0.0" + }, + "engines": { + "node": ">=14.17.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/claviska" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/addon-a11y": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.1.0.tgz", + "integrity": "sha512-2AU4mkTqHXaEd+v1A5wvjwOExwjJy8f+/d72nku8FKQI1+7n96D9zb1T5gQocw8oykSU9yxRR5jSn8ShDFudAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "axe-core": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.1.0" + } + }, + "node_modules/@storybook/addon-docs": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.1.0.tgz", + "integrity": "sha512-CVW2pc9iAfz1A6/L9S0z8XqKUON+u92xaOTC1x6d3WS8cyOT94nD7tfohT8aWydwvvmtwRHZJzl0aWnKUNgSJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/csf-plugin": "10.1.0", + "@storybook/icons": "^2.0.0", + "@storybook/react-dom-shim": "10.1.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.1.0" + } + }, + "node_modules/@storybook/addon-vitest": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.1.0.tgz", + "integrity": "sha512-OxEEizTE9s3W3ZmOI0Nl2jwIsAq9drDfOMtiIaE2tGk0nG9GLpVLD9AFrpZyMQ8MXhfp7mokjGS8yv2eyoiAlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@vitest/browser": "^3.0.0 || ^4.0.0", + "@vitest/browser-playwright": "^4.0.0", + "@vitest/runner": "^3.0.0 || ^4.0.0", + "storybook": "^10.1.0", + "vitest": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/runner": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@storybook/builder-vite": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.0.tgz", + "integrity": "sha512-5vVg8kZozI4Lg2voHdimm34jlEf6MrP9QzXS795dVA/KIR9BCVUzVw+yQBT9jRDWnp6Q4XbzqfILih/TVeh7iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "10.1.0", + "@vitest/mocker": "3.2.4", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.1.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.1.0.tgz", + "integrity": "sha512-oApFTBUlHusihoejKmlunprNA2BDpEBmBBx+PcCMzTpUbEiZNI95ZeGrlBuuEepF5dIpAIPAQQD09FibmkQlBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^2.3.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "esbuild": "*", + "rollup": "*", + "storybook": "^10.1.0", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true + }, + "node_modules/@storybook/icons": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", + "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "dev": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.1.0.tgz", + "integrity": "sha512-Zb86jWk3ch33lkCmsgqqqsv5q0ePk1wg2wAurM0BMQUAKTLPebdBvwC8esBsti8fp2ZGv0eNbJDGg3qzWYr/Uw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.1.0" + } + }, + "node_modules/@storybook/web-components": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-10.1.0.tgz", + "integrity": "sha512-LEDaC3HdywOL5D1JtpiXzQ5nhLLVf29PC0cA0e2TBxZX1Xhl7H9OAtN9zrDSYks7fdr2U0VVhjO2Pe7391dyHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "tiny-invariant": "^1.3.1", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "lit": "^2.0.0 || ^3.0.0", + "storybook": "^10.1.0" + } + }, + "node_modules/@storybook/web-components-vite": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/web-components-vite/-/web-components-vite-10.1.0.tgz", + "integrity": "sha512-kXJlQnRQednowER5QM2+VoFzkVs8slrOoZ6owR8MTm8/hHhag1h8enB4s6dZLDbRa+E/XX37VbFRZ4bP0xWlKQ==", + "dev": true, + "dependencies": { + "@storybook/builder-vite": "10.1.0", + "@storybook/web-components": "10.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.1.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true + }, + "node_modules/@vitest/browser": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.14.tgz", + "integrity": "sha512-vO0uqR8SnPTd8ykp14yaIuUyMZ9HEBYuoZrVdUp7RrEp76VEnkrX9fDkGnK0NyBdfWXB6cqp7BmqVekd8yKHFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.14", + "@vitest/utils": "4.0.14", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.14" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.14.tgz", + "integrity": "sha512-rUvyz6wX6wDjcYzf/7fgXYfca2bAu0Axoq/v9LYdELzcBSS9UKjnZ7MaMY4UDP78HHHCdmdtceuSao1s51ON8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.0.14", + "@vitest/mocker": "4.0.14", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.14" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.14.tgz", + "integrity": "sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.14", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/browser-playwright/node_modules/@vitest/spy": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.14.tgz", + "integrity": "sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/mocker": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.14.tgz", + "integrity": "sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.14", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/pretty-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/spy": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.14.tgz", + "integrity": "sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/utils": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.14.tgz", + "integrity": "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.14", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/browser/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.14.tgz", + "integrity": "sha512-EYHLqN/BY6b47qHH7gtMxAg++saoGmsjWmAq9MlXxAz4M0NcHh9iOyKhBZyU4yxZqOd8Xnqp80/5saeitz4Cng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.14", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.14", + "vitest": "4.0.14" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@vitest/pretty-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@vitest/utils": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.14.tgz", + "integrity": "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.14", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.14.tgz", + "integrity": "sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.14", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/utils": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.14.tgz", + "integrity": "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.14", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.14.tgz", + "integrity": "sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.14", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axe-core": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/chromatic": { + "version": "13.3.4", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.4.tgz", + "integrity": "sha512-TR5rvyH0ESXobBB3bV8jc87AEAFQC7/n+Eb4XWhJz6hW3YNxIQPVjcbgLv+a4oKHEl1dUBueWSoIQsOVGTd+RQ==", + "dev": true, + "license": "MIT", + "bin": { + "chroma": "dist/bin.js", + "chromatic": "dist/bin.js", + "chromatic-cli": "dist/bin.js" + }, + "peerDependencies": { + "@chromatic-com/cypress": "^0.*.* || ^1.0.0", + "@chromatic-com/playwright": "^0.*.* || ^1.0.0" + }, + "peerDependenciesMeta": { + "@chromatic-com/cypress": { + "optional": true + }, + "@chromatic-com/playwright": { + "optional": true + } + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/composed-offset-position": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz", + "integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==", + "peerDependencies": { + "@floating-ui/utils": "^0.2.5" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "peer": true + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, + "node_modules/dom-context": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/dom-context/-/dom-context-1.3.1.tgz", + "integrity": "sha512-u54uWFrh+18+HfnnL/h+a/FUH3TWFgF1oOdk2svbX2l5bzX+uj5YVjmcazrigk2o+3Q1bAROdP0JLKDNLQcQHw==" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-storybook": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.1.0.tgz", + "integrity": "sha512-5s/Hi9bKl6xSChPOlHwLrQDW+Nj6OXnl9nnQmbKtEQoCdEuSDbAEDc8zZ9NLAyD2rljE2N6Jhe9iP9+2lm43tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.8.1" + }, + "peerDependencies": { + "eslint": ">=8", + "storybook": "^10.1.0" + } + }, + "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-plugin-storybook/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-storybook/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-storybook/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extract-files": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/extract-files/-/extract-files-9.0.0.tgz", + "integrity": "sha512-CvdFfHkC95B4bBBk36hcEmvdR2awOdhhVUYH6S/zrVj3477zven/fJMYg7121h4T1xHZC+tetUpubpAhxwI7hQ==", + "engines": { + "node": "^10.17.0 || ^12.0.0 || >= 13.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/jaydenseric" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-equals": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz", + "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/filesize": { + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", + "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 10.4.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/form-data": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/graphql": { + "version": "15.10.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.10.1.tgz", + "integrity": "sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/graphql-combine-query": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/graphql-combine-query/-/graphql-combine-query-1.2.4.tgz", + "integrity": "sha512-Os0d5cS8nH1cRnejGyACda0lraPzMJJjtd21KrfjKi1NWgEgzXzP8s29cpLlne+Y+51/2mS4j/3aTaIlCYMexg==", + "peerDependencies": { + "graphql": "^15.1.0 || ^16.0.0" + } + }, + "node_modules/graphql-request": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-3.4.0.tgz", + "integrity": "sha512-acrTzidSlwAj8wBNO7Q/UQHS8T+z5qRGquCQRv9J1InwR01BBWV9ObnoE+JS5nCCEj8wSGS0yrDXVDoRiKZuOg==", + "dependencies": { + "cross-fetch": "^3.0.6", + "extract-files": "^9.0.0", + "form-data": "^3.0.0" + }, + "peerDependencies": { + "graphql": "14.x || 15.x" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/haunted": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/haunted/-/haunted-6.1.0.tgz", + "integrity": "sha512-FjqhqCkySCfZ3PHW72u3VUkdvmIlVt07KmQH7kpWcYjeb6q5ttSMogwezO8NKFkpo391Iegm14KjPoIVeGJriQ==", + "dependencies": { + "lit": "^3.0.0", + "lit-html": "^3.0.0" + } + }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/intl-messageformat": { + "version": "9.11.4", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-9.11.4.tgz", + "integrity": "sha512-77TSkNubIy/hsapz6LQpyR6OADcxhWdhSaboPb5flMaALCVkPvAIxr48AlPqaMl4r1anNcvR9rpLWVdwUY1IKg==", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.3", + "@formatjs/fast-memoize": "1.2.1", + "@formatjs/icu-messageformat-parser": "2.0.18", + "tslib": "^2.1.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "peer": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.1.0.tgz", + "integrity": "sha512-CXcRvMyTlnR53xMcKnuMzfCA5i/nfblTnnr74CZb6C4vG39eu6w51t7nKmU5MfLfbTgGItliNyjO/ciNPDqClg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lit": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", + "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.1.tgz", + "integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.4.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz", + "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/graphql": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-4.0.0.tgz", + "integrity": "sha512-gSrprq0fJ3EiOErzjdIZrjysVVmJ4uu1QWfCDss5LypA5OXvrMje5Ym5z6V6RLyJ2eF87lasX7t6a0AnFvZblg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qr-creator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/qr-creator/-/qr-creator-1.0.0.tgz", + "integrity": "sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "dev": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "npm": ">=2.0.0" + } + }, + "node_modules/rxjs/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/storybook": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.0.tgz", + "integrity": "sha512-RCTybwtyQaKRoU1Z8rWGv5h6ZN3+HelSM0WMMWKBsKgXZkpQ00vro1kd/tWILawxNiU2YS9Zo+4On5hx2Rm+8w==", + "dev": true, + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/expect": "3.2.4", + "@vitest/spy": "3.2.4", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "recast": "^0.23.5", + "semver": "^7.6.2", + "use-sync-external-store": "^1.5.0", + "ws": "^8.18.0" + }, + "bin": { + "storybook": "dist/bin/dispatcher.js" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", + "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.0" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.0.tgz", + "integrity": "sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.14.tgz", + "integrity": "sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.14", + "@vitest/mocker": "4.0.14", + "@vitest/pretty-format": "4.0.14", + "@vitest/runner": "4.0.14", + "@vitest/snapshot": "4.0.14", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.14", + "@vitest/browser-preview": "4.0.14", + "@vitest/browser-webdriverio": "4.0.14", + "@vitest/ui": "4.0.14", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/expect": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.14.tgz", + "integrity": "sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.14.tgz", + "integrity": "sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.14", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/pretty-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/spy": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.14.tgz", + "integrity": "sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/utils": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.14.tgz", + "integrity": "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.14", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", + "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + }, + "dependencies": { + "@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true + }, + "@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "peer": true, + "requires": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + }, + "@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true + }, + "@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "requires": { + "@babel/types": "^7.28.5" + } + }, + "@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "peer": true + }, + "@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + } + }, + "@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true + }, + "@chromatic-com/storybook": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-4.1.3.tgz", + "integrity": "sha512-hc0HO9GAV9pxqDE6fTVOV5KeLpTiCfV8Jrpk5ogKLiIgeq2C+NPjpt74YnrZTjiK8E19fYcMP+2WY9ZtX7zHmw==", + "dev": true, + "requires": { + "@neoconfetti/react": "^1.0.0", + "chromatic": "^13.3.3", + "filesize": "^10.0.12", + "jsonfile": "^6.1.0", + "strip-ansi": "^7.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true + }, + "strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "requires": { + "ansi-regex": "^6.0.1" + } + } + } + }, + "@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==" + }, + "@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "dev": true, + "optional": true + }, + "@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.4.3" + } + }, + "@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true + }, + "@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true + }, + "@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "requires": { + "@floating-ui/utils": "^0.2.10" + } + }, + "@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "requires": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" + }, + "@formatjs/ecma402-abstract": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.3.tgz", + "integrity": "sha512-kP/Buv5vVFMAYLHNvvUzr0lwRTU0u2WTy44Tqwku1X3C3lJ5dKqDCYVqA8wL+Y19Bq+MwHgxqd5FZJRCIsLRyQ==", + "requires": { + "@formatjs/intl-localematcher": "0.2.24", + "tslib": "^2.1.0" + } + }, + "@formatjs/fast-memoize": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz", + "integrity": "sha512-Rg0e76nomkz3vF9IPlKeV+Qynok0r7YZjL6syLz4/urSg0IbjPZCB/iYUMNsYA643gh4mgrX3T7KEIFIxJBQeg==", + "requires": { + "tslib": "^2.1.0" + } + }, + "@formatjs/icu-messageformat-parser": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.0.18.tgz", + "integrity": "sha512-vquIzsAJJmZ5jWVH8dEgUKcbG4yu3KqtyPet+q35SW5reLOvblkfeCXTRW2TpIwNXzdVqsJBwjbTiRiSU9JxwQ==", + "requires": { + "@formatjs/ecma402-abstract": "1.11.3", + "@formatjs/icu-skeleton-parser": "1.3.5", + "tslib": "^2.1.0" + } + }, + "@formatjs/icu-skeleton-parser": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.5.tgz", + "integrity": "sha512-Nhyo2/6kG7ZfgeEfo02sxviOuBcvtzH6SYUharj3DLCDJH3A/4OxkKcmx/2PWGX4bc6iSieh+FA94CsKDxnZBQ==", + "requires": { + "@formatjs/ecma402-abstract": "1.11.3", + "tslib": "^2.1.0" + } + }, + "@formatjs/intl-displaynames": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-5.4.2.tgz", + "integrity": "sha512-SLesCDan9NCMqBbHPXMEwqAcPn3tnbQw0sv0rssH1JQDLDUQYwKXL93kz30X3yskTyQS7N+pd47bhoIe3kbXyw==", + "requires": { + "@formatjs/ecma402-abstract": "1.11.3", + "@formatjs/intl-localematcher": "0.2.24", + "tslib": "^2.1.0" + } + }, + "@formatjs/intl-listformat": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-6.5.2.tgz", + "integrity": "sha512-/IYagQJkzTvpBlhhaysGYNgM3o72WBg1ZWZcpookkgXEJbINwLP5kVagHxmgxffYKs1CDzQ8rmKHghu2qR/7zw==", + "requires": { + "@formatjs/ecma402-abstract": "1.11.3", + "@formatjs/intl-localematcher": "0.2.24", + "tslib": "^2.1.0" + } + }, + "@formatjs/intl-localematcher": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.24.tgz", + "integrity": "sha512-K/HRGo6EMnCbhpth/y3u4rW4aXkmQNqRe1L2G+Y5jNr3v0gYhvaucV8WixNju/INAMbPBlbsRBRo/nfjnoOnxQ==", + "requires": { + "tslib": "^2.1.0" + } + }, + "@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "dependencies": { + "brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true + }, + "@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true + }, + "@inquirer/ansi": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.6.tgz", + "integrity": "sha512-I/INw4sHGlVZ/afZOckpLiDP9SmbMl1g/GCqeHjLw1Afw/0PlRs2tRFgTGWmdI0hoNuWZn3y2iHNmG1vyECyQQ==", + "dev": true + }, + "@inquirer/confirm": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.0.tgz", + "integrity": "sha512-USpeB76eqK7yGricDlGAupxWlp4a59qpeZOoNWaxO/nJln7agpJveyNkQ1d5u8YXG6TOqxZtQpKPORQQDrdVsA==", + "dev": true, + "requires": { + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" + } + }, + "@inquirer/core": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.0.tgz", + "integrity": "sha512-joR1YS2sI0us+9d0I8ViqFbrRLONO8CFTuyvBX4ZVBSch+VsZiugUABdrhBXXJR1VyEzvpz5SQCix3keETQ58g==", + "dev": true, + "requires": { + "@inquirer/ansi": "^2.0.6", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^4.0.0", + "signal-exit": "^4.1.0" + } + }, + "@inquirer/figures": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.6.tgz", + "integrity": "sha512-dsZgQtH2t5Q6ah3aPbZbeEZAxsD9qQu0DXf01AltuEfRTm+NoLN6+rLVbr+4edeEbNCp/wBNM6mALRWtsQpfkw==", + "dev": true + }, + "@inquirer/type": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.6.tgz", + "integrity": "sha512-J+9tdxOskuYuGjsvGaq00AamhDgjR7anhEW2dP4QdQpFCMPngCeC/bCYWQ5NsMWZRdsy53is7kAHb/+7cwDk2g==", + "dev": true, + "requires": {} + }, + "@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@lit-labs/ssr-dom-shim": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz", + "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==" + }, + "@lit/react": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz", + "integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==", + "requires": {} + }, + "@lit/reactive-element": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.1.tgz", + "integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==", + "requires": { + "@lit-labs/ssr-dom-shim": "^1.4.0" + } + }, + "@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "dev": true, + "requires": { + "@types/mdx": "^2.0.0" + } + }, + "@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "requires": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "dependencies": { + "@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + } + } + }, + "@neoconfetti/react": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@neoconfetti/react/-/react-1.0.0.tgz", + "integrity": "sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==", + "dev": true + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true + }, + "@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "requires": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, + "@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true + }, + "@raisins/schema": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@raisins/schema/-/schema-1.2.0.tgz", + "integrity": "sha512-4o/9FpHz9PTYdxjjp/s7RJDXtlS6AK1E66f8ZStZdMC7HKFpvXKk3zzh0kPaYn07VeQGn2vnM0lfYMQVBx+QUw==" + }, + "@rollup/plugin-alias": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-6.0.0.tgz", + "integrity": "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==", + "dev": true, + "requires": {} + }, + "@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + } + }, + "@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + } + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "dev": true, + "optional": true + }, + "@saasquatch/component-boilerplate": { + "version": "1.6.10-3", + "resolved": "https://registry.npmjs.org/@saasquatch/component-boilerplate/-/component-boilerplate-1.6.10-3.tgz", + "integrity": "sha512-zCY3oC/lxf1z2ZudfjufyC4Jpjy6gsGttKJGzBJA9SY0yWONGwwMstB1aR1aJuDOLAD0aZsxbWYIhOMYzdcaww==", + "requires": { + "@formatjs/intl": "^1.8.2", + "@saasquatch/component-environment": "^1.0.10", + "@saasquatch/dom-context-hooks": "^1.0.4-0", + "@saasquatch/universal-hooks": "^1.0.1", + "@wry/equality": "^0.5.2", + "debounce": "^1.2.0", + "dom-context": "^1.3.1", + "fast-equals": "^5.0.1", + "fast-memoize": "^2.5.2", + "graphql": "^15.5.0", + "graphql-combine-query": "^1.2.3", + "graphql-request": "3.4.0", + "jsonpointer": "^4.1.0", + "jwt-decode": "^3.1.2", + "nanoid": "^3.1.23", + "rxjs": "^6.6.7" + }, + "dependencies": { + "@formatjs/intl": { + "version": "1.18.5", + "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-1.18.5.tgz", + "integrity": "sha512-5jiC7neCMXMAa/uQU68plY3vxQqtDjiFPkvc6fEuCmNoVZkIya9uMG8uqM2Z0BOX6xg0K1ohHuj+VLHDDx1fEw==", + "requires": { + "@formatjs/ecma402-abstract": "1.11.3", + "@formatjs/fast-memoize": "1.2.1", + "@formatjs/icu-messageformat-parser": "2.0.18", + "@formatjs/intl-displaynames": "5.4.2", + "@formatjs/intl-listformat": "6.5.2", + "intl-messageformat": "9.11.4", + "tslib": "^2.1.0" + } + } + } + }, + "@saasquatch/component-environment": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@saasquatch/component-environment/-/component-environment-1.0.10.tgz", + "integrity": "sha512-aKammGzDkgTh9oOaKXUQbSh+m7wM66AvkJz+HCtO6YA7X+2ph16itHiQUq7jO/vP7SyfVyrTztUH0/imC1uaXw==", + "requires": { + "@wry/equality": "^0.5.2", + "dom-context": "^1.3.1", + "jwt-decode": "^3.1.2" + } + }, + "@saasquatch/dom-context-hooks": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@saasquatch/dom-context-hooks/-/dom-context-hooks-1.0.5.tgz", + "integrity": "sha512-YV98E6MFX3eb6bIuUS31+SlNDvcxGXUqfwuoIdBB95AAnbxzv/OHMcaaLZxExj7MpqrDU6pEPLqyIvZO9J4opg==", + "requires": { + "@saasquatch/universal-hooks": "^1.0.0", + "dom-context": "^1.3.1" + } + }, + "@saasquatch/universal-hooks": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@saasquatch/universal-hooks/-/universal-hooks-1.0.1.tgz", + "integrity": "sha512-jxyaBJzvc3TsGQjWvXtfeoOblrSMPzsZlnEdjHPqbAfWL3eIsgTAXHP79L+OKz1TAFrXU+LmLokvgGgZPocwcQ==" + }, + "@shoelace-style/animations": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@shoelace-style/animations/-/animations-1.2.0.tgz", + "integrity": "sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==" + }, + "@shoelace-style/localize": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.1.tgz", + "integrity": "sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA==" + }, + "@shoelace-style/shoelace": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/@shoelace-style/shoelace/-/shoelace-2.20.1.tgz", + "integrity": "sha512-FSghU95jZPGbwr/mybVvk66qRZYpx5FkXL+vLNpy1Vp8UsdwSxXjIHE3fsvMbKWTKi9UFfewHTkc5e7jAqRYoQ==", + "requires": { + "@ctrl/tinycolor": "^4.1.0", + "@floating-ui/dom": "^1.6.12", + "@lit/react": "^1.0.6", + "@shoelace-style/animations": "^1.2.0", + "@shoelace-style/localize": "^3.2.1", + "composed-offset-position": "^0.0.6", + "lit": "^3.2.1", + "qr-creator": "^1.0.0" + } + }, + "@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true + }, + "@storybook/addon-a11y": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.1.0.tgz", + "integrity": "sha512-2AU4mkTqHXaEd+v1A5wvjwOExwjJy8f+/d72nku8FKQI1+7n96D9zb1T5gQocw8oykSU9yxRR5jSn8ShDFudAA==", + "dev": true, + "requires": { + "@storybook/global": "^5.0.0", + "axe-core": "^4.2.0" + } + }, + "@storybook/addon-docs": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.1.0.tgz", + "integrity": "sha512-CVW2pc9iAfz1A6/L9S0z8XqKUON+u92xaOTC1x6d3WS8cyOT94nD7tfohT8aWydwvvmtwRHZJzl0aWnKUNgSJw==", + "dev": true, + "requires": { + "@mdx-js/react": "^3.0.0", + "@storybook/csf-plugin": "10.1.0", + "@storybook/icons": "^2.0.0", + "@storybook/react-dom-shim": "10.1.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + } + }, + "@storybook/addon-vitest": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.1.0.tgz", + "integrity": "sha512-OxEEizTE9s3W3ZmOI0Nl2jwIsAq9drDfOMtiIaE2tGk0nG9GLpVLD9AFrpZyMQ8MXhfp7mokjGS8yv2eyoiAlA==", + "dev": true, + "requires": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.0" + } + }, + "@storybook/builder-vite": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.0.tgz", + "integrity": "sha512-5vVg8kZozI4Lg2voHdimm34jlEf6MrP9QzXS795dVA/KIR9BCVUzVw+yQBT9jRDWnp6Q4XbzqfILih/TVeh7iA==", + "dev": true, + "requires": { + "@storybook/csf-plugin": "10.1.0", + "@vitest/mocker": "3.2.4", + "ts-dedent": "^2.0.0" + } + }, + "@storybook/csf-plugin": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.1.0.tgz", + "integrity": "sha512-oApFTBUlHusihoejKmlunprNA2BDpEBmBBx+PcCMzTpUbEiZNI95ZeGrlBuuEepF5dIpAIPAQQD09FibmkQlBQ==", + "dev": true, + "requires": { + "unplugin": "^2.3.5" + } + }, + "@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true + }, + "@storybook/icons": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", + "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "dev": true, + "requires": {} + }, + "@storybook/react-dom-shim": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.1.0.tgz", + "integrity": "sha512-Zb86jWk3ch33lkCmsgqqqsv5q0ePk1wg2wAurM0BMQUAKTLPebdBvwC8esBsti8fp2ZGv0eNbJDGg3qzWYr/Uw==", + "dev": true, + "requires": {} + }, + "@storybook/web-components": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/web-components/-/web-components-10.1.0.tgz", + "integrity": "sha512-LEDaC3HdywOL5D1JtpiXzQ5nhLLVf29PC0cA0e2TBxZX1Xhl7H9OAtN9zrDSYks7fdr2U0VVhjO2Pe7391dyHg==", + "dev": true, + "requires": { + "@storybook/global": "^5.0.0", + "tiny-invariant": "^1.3.1", + "ts-dedent": "^2.0.0" + } + }, + "@storybook/web-components-vite": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@storybook/web-components-vite/-/web-components-vite-10.1.0.tgz", + "integrity": "sha512-kXJlQnRQednowER5QM2+VoFzkVs8slrOoZ6owR8MTm8/hHhag1h8enB4s6dZLDbRa+E/XX37VbFRZ4bP0xWlKQ==", + "dev": true, + "requires": { + "@storybook/builder-vite": "10.1.0", + "@storybook/web-components": "10.1.0" + } + }, + "@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "peer": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + } + }, + "@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "dependencies": { + "dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + } + } + }, + "@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "requires": {} + }, + "@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, + "@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "requires": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true + }, + "@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "dev": true, + "requires": { + "undici-types": "~6.21.0" + } + }, + "@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "peer": true, + "requires": { + "csstype": "^3.2.2" + } + }, + "@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true + }, + "@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, + "@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + } + }, + "@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + } + }, + "@typescript-eslint/project-service": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "dev": true, + "requires": { + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", + "debug": "^4.3.4" + }, + "dependencies": { + "@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true + } + } + }, + "@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + } + }, + "@typescript-eslint/tsconfig-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "dev": true, + "requires": {} + }, + "@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "requires": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + } + }, + "@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + } + }, + "@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + } + }, + "@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true + }, + "@vitest/browser": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.14.tgz", + "integrity": "sha512-vO0uqR8SnPTd8ykp14yaIuUyMZ9HEBYuoZrVdUp7RrEp76VEnkrX9fDkGnK0NyBdfWXB6cqp7BmqVekd8yKHFQ==", + "dev": true, + "requires": { + "@vitest/mocker": "4.0.14", + "@vitest/utils": "4.0.14", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "dependencies": { + "@vitest/mocker": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.14.tgz", + "integrity": "sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==", + "dev": true, + "requires": { + "@vitest/spy": "4.0.14", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + } + }, + "@vitest/pretty-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", + "dev": true, + "requires": { + "tinyrainbow": "^3.0.3" + } + }, + "@vitest/spy": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.14.tgz", + "integrity": "sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==", + "dev": true + }, + "@vitest/utils": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.14.tgz", + "integrity": "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==", + "dev": true, + "requires": { + "@vitest/pretty-format": "4.0.14", + "tinyrainbow": "^3.0.3" + } + }, + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + }, + "tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true + } + } + }, + "@vitest/browser-playwright": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.14.tgz", + "integrity": "sha512-rUvyz6wX6wDjcYzf/7fgXYfca2bAu0Axoq/v9LYdELzcBSS9UKjnZ7MaMY4UDP78HHHCdmdtceuSao1s51ON8A==", + "dev": true, + "requires": { + "@vitest/browser": "4.0.14", + "@vitest/mocker": "4.0.14", + "tinyrainbow": "^3.0.3" + }, + "dependencies": { + "@vitest/mocker": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.14.tgz", + "integrity": "sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==", + "dev": true, + "requires": { + "@vitest/spy": "4.0.14", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + } + }, + "@vitest/spy": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.14.tgz", + "integrity": "sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==", + "dev": true + }, + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + }, + "tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true + } + } + }, + "@vitest/coverage-v8": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.14.tgz", + "integrity": "sha512-EYHLqN/BY6b47qHH7gtMxAg++saoGmsjWmAq9MlXxAz4M0NcHh9iOyKhBZyU4yxZqOd8Xnqp80/5saeitz4Cng==", + "dev": true, + "requires": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.14", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "dependencies": { + "@vitest/pretty-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", + "dev": true, + "requires": { + "tinyrainbow": "^3.0.3" + } + }, + "@vitest/utils": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.14.tgz", + "integrity": "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==", + "dev": true, + "requires": { + "@vitest/pretty-format": "4.0.14", + "tinyrainbow": "^3.0.3" + } + }, + "tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true + } + } + }, + "@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "requires": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + } + }, + "@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "requires": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "dependencies": { + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + } + } + }, + "@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "requires": { + "tinyrainbow": "^2.0.0" + } + }, + "@vitest/runner": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.14.tgz", + "integrity": "sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==", + "dev": true, + "requires": { + "@vitest/utils": "4.0.14", + "pathe": "^2.0.3" + }, + "dependencies": { + "@vitest/pretty-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", + "dev": true, + "requires": { + "tinyrainbow": "^3.0.3" + } + }, + "@vitest/utils": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.14.tgz", + "integrity": "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==", + "dev": true, + "requires": { + "@vitest/pretty-format": "4.0.14", + "tinyrainbow": "^3.0.3" + } + }, + "tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true + } + } + }, + "@vitest/snapshot": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.14.tgz", + "integrity": "sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==", + "dev": true, + "requires": { + "@vitest/pretty-format": "4.0.14", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "dependencies": { + "@vitest/pretty-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", + "dev": true, + "requires": { + "tinyrainbow": "^3.0.3" + } + }, + "tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true + } + } + }, + "@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "requires": { + "tinyspy": "^4.0.3" + } + }, + "@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "requires": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + } + }, + "@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "requires": { + "tslib": "^2.3.0" + } + }, + "acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "requires": { + "dequal": "^2.0.3" + } + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true + }, + "ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "requires": { + "tslib": "^2.0.1" + } + }, + "ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + }, + "dependencies": { + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + }, + "js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + } + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axe-core": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "dev": true + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "requires": { + "fill-range": "^7.1.1" + } + }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "requires": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true + }, + "chromatic": { + "version": "13.3.4", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.4.tgz", + "integrity": "sha512-TR5rvyH0ESXobBB3bV8jc87AEAFQC7/n+Eb4XWhJz6hW3YNxIQPVjcbgLv+a4oKHEl1dUBueWSoIQsOVGTd+RQ==", + "dev": true, + "requires": {} + }, + "cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "composed-offset-position": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz", + "integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==", + "requires": {} + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true + }, + "cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "requires": { + "node-fetch": "^2.7.0" + } + }, + "cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "peer": true + }, + "debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, + "dom-context": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/dom-context/-/dom-context-1.3.1.tgz", + "integrity": "sha512-u54uWFrh+18+HfnnL/h+a/FUH3TWFgF1oOdk2svbX2l5bzX+uj5YVjmcazrigk2o+3Q1bAROdP0JLKDNLQcQHw==" + }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, + "esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "dependencies": { + "brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "eslint-plugin-storybook": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.1.0.tgz", + "integrity": "sha512-5s/Hi9bKl6xSChPOlHwLrQDW+Nj6OXnl9nnQmbKtEQoCdEuSDbAEDc8zZ9NLAyD2rljE2N6Jhe9iP9+2lm43tQ==", + "dev": true, + "requires": { + "@typescript-eslint/utils": "^8.8.1" + }, + "dependencies": { + "@typescript-eslint/scope-manager": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" + } + }, + "@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "dev": true, + "requires": { + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + } + }, + "@typescript-eslint/utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.48.0", + "eslint-visitor-keys": "^4.2.1" + } + }, + "eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true + }, + "ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "requires": {} + } + } + }, + "eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true + }, + "espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "requires": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true + }, + "extract-files": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/extract-files/-/extract-files-9.0.0.tgz", + "integrity": "sha512-CvdFfHkC95B4bBBk36hcEmvdR2awOdhhVUYH6S/zrVj3477zven/fJMYg7121h4T1xHZC+tetUpubpAhxwI7hQ==" + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-equals": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz", + "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==" + }, + "fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==" + }, + "fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true + }, + "fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "requires": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "requires": { + "fast-string-width": "^3.0.2" + } + }, + "fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "filesize": { + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", + "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", + "dev": true + }, + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "requires": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "form-data": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, + "get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "requires": { + "resolve-pkg-maps": "^1.0.0" + } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "dependencies": { + "brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "optional": true + }, + "graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "graphql": { + "version": "15.10.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.10.1.tgz", + "integrity": "sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg==" + }, + "graphql-combine-query": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/graphql-combine-query/-/graphql-combine-query-1.2.4.tgz", + "integrity": "sha512-Os0d5cS8nH1cRnejGyACda0lraPzMJJjtd21KrfjKi1NWgEgzXzP8s29cpLlne+Y+51/2mS4j/3aTaIlCYMexg==", + "requires": {} + }, + "graphql-request": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-3.4.0.tgz", + "integrity": "sha512-acrTzidSlwAj8wBNO7Q/UQHS8T+z5qRGquCQRv9J1InwR01BBWV9ObnoE+JS5nCCEj8wSGS0yrDXVDoRiKZuOg==", + "requires": { + "cross-fetch": "^3.0.6", + "extract-files": "^9.0.0", + "form-data": "^3.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "requires": { + "has-symbols": "^1.0.3" + } + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, + "haunted": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/haunted/-/haunted-6.1.0.tgz", + "integrity": "sha512-FjqhqCkySCfZ3PHW72u3VUkdvmIlVt07KmQH7kpWcYjeb6q5ttSMogwezO8NKFkpo391Iegm14KjPoIVeGJriQ==", + "requires": { + "lit": "^3.0.0", + "lit-html": "^3.0.0" + } + }, + "headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "requires": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true + }, + "import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "intl-messageformat": { + "version": "9.11.4", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-9.11.4.tgz", + "integrity": "sha512-77TSkNubIy/hsapz6LQpyR6OADcxhWdhSaboPb5flMaALCVkPvAIxr48AlPqaMl4r1anNcvR9rpLWVdwUY1IKg==", + "requires": { + "@formatjs/ecma402-abstract": "1.11.3", + "@formatjs/fast-memoize": "1.2.1", + "@formatjs/icu-messageformat-parser": "2.0.18", + "tslib": "^2.1.0" + } + }, + "is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "requires": { + "hasown": "^2.0.2" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true + }, + "istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + } + }, + "istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + } + }, + "istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "peer": true + }, + "js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "jsonpointer": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.1.0.tgz", + "integrity": "sha512-CXcRvMyTlnR53xMcKnuMzfCA5i/nfblTnnr74CZb6C4vG39eu6w51t7nKmU5MfLfbTgGItliNyjO/ciNPDqClg==" + }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, + "keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "requires": { + "json-buffer": "3.0.1" + } + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "lit": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", + "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", + "requires": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "lit-element": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.1.tgz", + "integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==", + "requires": { + "@lit-labs/ssr-dom-shim": "^1.4.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "lit-html": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz", + "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==", + "requires": { + "@types/trusted-types": "^2.0.2" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true + }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "requires": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "requires": { + "semver": "^7.5.3" + } + }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "requires": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "dependencies": { + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + } + } + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, + "minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "dev": true, + "requires": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "dependencies": { + "graphql": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "dev": true + }, + "type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "requires": { + "tagged-tag": "^1.0.0" + } + } + } + }, + "mute-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-4.0.0.tgz", + "integrity": "sha512-gSrprq0fJ3EiOErzjdIZrjysVVmJ4uu1QWfCDss5LypA5OXvrMje5Ym5z6V6RLyJ2eF87lasX7t6a0AnFvZblg==", + "dev": true + }, + "nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + } + }, + "outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true + }, + "pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "requires": { + "pngjs": "^7.0.0" + } + }, + "playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.57.0" + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + } + } + }, + "playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true + }, + "pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true + }, + "postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "requires": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true + }, + "pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "peer": true, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true + } + } + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, + "qr-creator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/qr-creator/-/qr-creator-1.0.0.tgz", + "integrity": "sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==" + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "dev": true + }, + "react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "dev": true, + "requires": { + "scheduler": "^0.27.0" + } + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true + }, + "recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "requires": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + } + }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true + }, + "resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "requires": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true + }, + "rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true + }, + "reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@types/estree": "1.0.8", + "fsevents": "~2.3.2" + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "requires": { + "tslib": "^1.9.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true + }, + "semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true + }, + "set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + }, + "sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "requires": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + } + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, + "stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true + }, + "std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "storybook": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.0.tgz", + "integrity": "sha512-RCTybwtyQaKRoU1Z8rWGv5h6ZN3+HelSM0WMMWKBsKgXZkpQ00vro1kd/tWILawxNiU2YS9Zo+4On5hx2Rm+8w==", + "dev": true, + "requires": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/expect": "3.2.4", + "@vitest/spy": "3.2.4", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "recast": "^0.23.5", + "semver": "^7.6.2", + "use-sync-external-store": "^1.5.0", + "ws": "^8.18.0" + } + }, + "strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true + }, + "tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + } + }, + "tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true + }, + "tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true + }, + "tldts": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", + "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", + "dev": true, + "requires": { + "tldts-core": "^7.4.0" + } + }, + "tldts-core": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.0.tgz", + "integrity": "sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true + }, + "tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "requires": { + "tldts": "^7.0.5" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "requires": {} + }, + "ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "requires": { + "esbuild": "~0.27.0", + "fsevents": "~2.3.3", + "get-tsconfig": "^4.7.5" + }, + "dependencies": { + "@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "dev": true, + "optional": true + }, + "esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + } + } + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true + }, + "undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true + }, + "unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "requires": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + } + }, + "until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "requires": {} + }, + "vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "requires": { + "esbuild": "^0.21.3", + "fsevents": "~2.3.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + } + }, + "vitest": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.14.tgz", + "integrity": "sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==", + "dev": true, + "requires": { + "@vitest/expect": "4.0.14", + "@vitest/mocker": "4.0.14", + "@vitest/pretty-format": "4.0.14", + "@vitest/runner": "4.0.14", + "@vitest/snapshot": "4.0.14", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "dependencies": { + "@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "dev": true, + "optional": true + }, + "@vitest/expect": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.14.tgz", + "integrity": "sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==", + "dev": true, + "requires": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + } + }, + "@vitest/mocker": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.14.tgz", + "integrity": "sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==", + "dev": true, + "requires": { + "@vitest/spy": "4.0.14", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + } + }, + "@vitest/pretty-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", + "dev": true, + "requires": { + "tinyrainbow": "^3.0.3" + } + }, + "@vitest/spy": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.14.tgz", + "integrity": "sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==", + "dev": true + }, + "@vitest/utils": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.14.tgz", + "integrity": "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==", + "dev": true, + "requires": { + "@vitest/pretty-format": "4.0.14", + "tinyrainbow": "^3.0.3" + } + }, + "chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true + }, + "esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + }, + "tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true + }, + "vite": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", + "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "dev": true, + "requires": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "fsevents": "~2.3.3", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + } + } + } + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "requires": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + } + }, + "word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "requires": {} + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + } + } +} diff --git a/packages/lit-components/package.json b/packages/lit-components/package.json new file mode 100644 index 0000000000..e6180723a8 --- /dev/null +++ b/packages/lit-components/package.json @@ -0,0 +1,80 @@ +{ + "name": "@saasquatch/lit-components", + "version": "0.0.0-7", + "description": "Web components built with Lit and Haunted for state management", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "raisins": "dist/docs/raisins.json", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "author": "SaaSquatch", + "license": "MIT", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build && tsc --emitDeclarationOnly --outDir dist && npm run generate:raisins", + "generate:raisins": "tsx scripts/generate-raisins.ts", + "preview": "vite preview", + "type-check": "tsc --noEmit", + "lint": "eslint src --ext .ts", + "format": "prettier --write src", + "test": "vitest run --project unit", + "test:watch": "vitest --project unit", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "keywords": [ + "web-components", + "lit", + "haunted", + "vite" + ], + "peerDependencies": { + "lit": "^3.0.0" + }, + "dependencies": { + "@raisins/schema": "^1.2.0", + "@saasquatch/component-boilerplate": "^1.6.10-3", + "@saasquatch/universal-hooks": "^1.0.1", + "@shoelace-style/shoelace": "^2.20.1", + "haunted": "^6.1.0", + "lit": "^3.0.0" + }, + "devDependencies": { + "@chromatic-com/storybook": "^4.1.3", + "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@storybook/addon-a11y": "^10.1.0", + "@storybook/addon-docs": "^10.1.0", + "@storybook/addon-vitest": "^10.1.0", + "@storybook/web-components-vite": "^10.1.0", + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^7.16.0", + "@typescript-eslint/parser": "^7.16.0", + "@vitest/browser-playwright": "^4.0.14", + "@vitest/coverage-v8": "^4.0.14", + "eslint": "^8.57.0", + "eslint-plugin-storybook": "^10.1.0", + "playwright": "^1.57.0", + "prettier": "^3.3.2", + "storybook": "^10.1.0", + "tsx": "^4.21.0", + "typescript": "^5.5.3", + "vite": "^5.4.0", + "msw": "^2.4.9", + "vitest": "^4.0.14" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/saasquatch/program-tools.git", + "directory": "packages/lit-components" + } +} diff --git a/packages/lit-components/scripts/generate-raisins.ts b/packages/lit-components/scripts/generate-raisins.ts new file mode 100644 index 0000000000..09e48ff38f --- /dev/null +++ b/packages/lit-components/scripts/generate-raisins.ts @@ -0,0 +1,389 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as ts from 'typescript'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import type { Package, CustomElement, Attribute } from '@raisins/schema'; + +/** + * Extracts JSDoc comment from a node + */ +function getJsDocComment(node: ts.Node, sourceFile: ts.SourceFile): string { + const fullText = sourceFile.getFullText(); + const nodeStart = node.getFullStart(); + const commentRanges = ts.getLeadingCommentRanges(fullText, nodeStart); + + if (!commentRanges || commentRanges.length === 0) return ''; + + // Get the last comment range (closest to the node) + const lastComment = commentRanges[commentRanges.length - 1]; + const commentText = fullText.substring(lastComment.pos, lastComment.end); + + // Parse JSDoc comment + if (commentText.startsWith('/**')) { + const lines = commentText + .split('\n') + .map(line => line.trim()) + .filter(line => line && line !== '/**' && line !== '*/' && !line.startsWith('* @')) + .map(line => line.replace(/^\* ?/, '')) + .join(' ') + .trim(); + return lines; + } + + return ''; +} + +/** + * Extracts JSDoc tags from a node + */ +function getJsDocTags(node: ts.Node, sourceFile: ts.SourceFile): Map { + const tags = new Map(); + const fullText = sourceFile.getFullText(); + const nodeStart = node.getFullStart(); + const commentRanges = ts.getLeadingCommentRanges(fullText, nodeStart); + + if (!commentRanges || commentRanges.length === 0) return tags; + + // Get the last comment range (closest to the node) + const lastComment = commentRanges[commentRanges.length - 1]; + const commentText = fullText.substring(lastComment.pos, lastComment.end); + + // Parse JSDoc tags + if (commentText.startsWith('/**')) { + const lines = commentText.split('\n'); + for (const line of lines) { + const match = line.match(/^\s*\*\s*@(\w+)\s+(.*)$/); + if (match) { + const [, tagName, tagValue] = match; + tags.set(tagName, tagValue.trim()); + } + } + } + + return tags; +} + +/** + * Converts kebab-case to camelCase + */ +function kebabToCamel(str: string): string { + return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +/** + * Extracts type information from a TypeScript type + */ +function extractTypeInfo(type: ts.Type, checker: ts.TypeChecker): { type: string; enum?: any[] } { + const typeString = checker.typeToString(type); + + // Check for union types (string literals) + if (type.isUnion()) { + const literalValues = type.types + .map(t => { + if (t.isStringLiteral()) { + return t.value; + } + if (t.isNumberLiteral()) { + return t.value; + } + return null; + }) + .filter(v => v !== null); + + if (literalValues.length > 0) { + return { + type: typeof literalValues[0] === 'number' ? 'number' : 'string', + enum: literalValues + }; + } + } + + // Map TypeScript types to raisins types + if (typeString.includes('string')) return { type: 'string' }; + if (typeString.includes('number')) return { type: 'number' }; + if (typeString.includes('boolean')) return { type: 'boolean' }; + + return { type: typeString }; +} + +/** + * Extracts attributes from a component's props interface + */ +function extractAttributes( + propsInterface: ts.InterfaceDeclaration, + observedAttributes: string[], + sourceFile: ts.SourceFile, + checker: ts.TypeChecker +): Attribute[] { + const attributes: Attribute[] = []; + + propsInterface.members.forEach(member => { + if (ts.isPropertySignature(member) && member.name) { + const propName = member.name.getText(); + const kebabName = propName.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`); + + // Only include if it's in observedAttributes + if (!observedAttributes.includes(kebabName)) { + return; + } + + const description = getJsDocComment(member, sourceFile); + const tags = getJsDocTags(member, sourceFile); + + let type = 'string'; + let enumValues: any[] | undefined; + + if (member.type) { + const typeNode = checker.getTypeAtLocation(member.type); + const typeInfo = extractTypeInfo(typeNode, checker); + type = typeInfo.type; + enumValues = typeInfo.enum; + } + + const attribute: Attribute = { + name: kebabName, + title: tags.get('uiName') || tags.get('title') || propName, + description: description, + type: type, + required: !member.questionToken, + }; + + if (enumValues) { + attribute.enum = enumValues; + } + + if (tags.has('default')) { + attribute.default = tags.get('default'); + } + + if (tags.has('uiWidget')) { + attribute.uiWidget = tags.get('uiWidget'); + } + + if (tags.has('uiGroup')) { + attribute.uiGroup = tags.get('uiGroup'); + } + + if (tags.has('uiOrder')) { + const order = parseInt(tags.get('uiOrder') || '0', 10); + if (!isNaN(order)) { + attribute.uiOrder = order; + } + } + + if (tags.has('uiEnum')) { + try { + const enumStr = tags.get('uiEnum') || '[]'; + attribute.enum = JSON.parse(enumStr); + } catch (e) { + // Ignore parse errors + } + } + + if (tags.has('uiEnumNames')) { + try { + const enumNamesStr = tags.get('uiEnumNames') || '[]'; + attribute.enumNames = JSON.parse(enumNamesStr); + } catch (e) { + // Ignore parse errors + } + } + + if (tags.has('minimum')) { + const min = parseFloat(tags.get('minimum') || '0'); + if (!isNaN(min)) { + attribute.minimum = min; + } + } + + if (tags.has('maximum')) { + const max = parseFloat(tags.get('maximum') || '0'); + if (!isNaN(max)) { + attribute.maximum = max; + } + } + + attributes.push(attribute); + } + }); + + return attributes; +} + +/** + * Extracts component information from a TypeScript source file + */ +function extractComponentInfo(sourceFile: ts.SourceFile, checker: ts.TypeChecker): CustomElement | null { + let componentInfo: CustomElement | null = null; + let propsInterface: ts.InterfaceDeclaration | null = null; + let observedAttributes: string[] = []; + let tagName = ''; + let componentExport: ts.Node | null = null; + + function visit(node: ts.Node) { + // Find the props interface + if (ts.isInterfaceDeclaration(node) && node.name.text.endsWith('Props')) { + propsInterface = node; + } + + // Find the useComponent call + if (ts.isVariableStatement(node)) { + const declaration = node.declarationList.declarations[0]; + if (declaration && ts.isVariableDeclaration(declaration)) { + const init = declaration.initializer; + if (init && ts.isCallExpression(init)) { + const expression = init.expression; + if (ts.isIdentifier(expression) && expression.text === 'useComponent') { + componentExport = node; + + // Extract tag name (second argument) + if (init.arguments.length > 1 && ts.isStringLiteral(init.arguments[1])) { + tagName = init.arguments[1].text; + } + + // Extract observed attributes (third argument) + if (init.arguments.length > 2) { + const thirdArg = init.arguments[2]; + let arrayExpr: ts.ArrayLiteralExpression | undefined; + + // Handle direct array literal or array with "as const" + if (ts.isArrayLiteralExpression(thirdArg)) { + arrayExpr = thirdArg; + } else if (ts.isAsExpression(thirdArg) && ts.isArrayLiteralExpression(thirdArg.expression)) { + arrayExpr = thirdArg.expression; + } + + if (arrayExpr) { + observedAttributes = arrayExpr.elements + .filter(ts.isStringLiteral) + .map(el => el.text); + } + } + } + } + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + if (propsInterface && tagName && componentExport) { + const description = getJsDocComment(componentExport, sourceFile); + const tags = getJsDocTags(componentExport, sourceFile); + const attributes = extractAttributes(propsInterface, observedAttributes, sourceFile, checker); + + componentInfo = { + tagName: tagName, + title: tags.get('uiName') || tags.get('title') || tagName, + description: description, + attributes: attributes, + }; + + // Add additional metadata from JSDoc tags + if (tags.has('validParents')) { + try { + const parentsStr = tags.get('validParents') || '[]'; + componentInfo.validParents = JSON.parse(parentsStr); + } catch (e) { + // Ignore parse errors + } + } + + if (tags.has('slotEditor')) { + componentInfo.slotEditor = tags.get('slotEditor'); + } + + if (tags.has('exampleGroup')) { + componentInfo.exampleGroup = tags.get('exampleGroup'); + } + + if (tags.has('example')) { + const exampleText = tags.get('example'); + if (exampleText) { + const parts = exampleText.split(' - '); + componentInfo.examples = [{ + title: parts[0]?.trim() || 'Example', + content: parts[1]?.trim() || exampleText, + tagName: tagName + }]; + } + } + } + + return componentInfo; +} + +/** + * Generates raisins.json from TypeScript component files + */ +function generateRaisins(srcDir: string, outFile: string) { + const configPath = ts.findConfigFile(srcDir, ts.sys.fileExists, 'tsconfig.json'); + if (!configPath) { + throw new Error('Could not find tsconfig.json'); + } + + const { config } = ts.readConfigFile(configPath, ts.sys.readFile); + const { options, fileNames } = ts.parseJsonConfigFileContent( + config, + ts.sys, + path.dirname(configPath) + ); + + const program = ts.createProgram(fileNames, options); + const checker = program.getTypeChecker(); + + const components: CustomElement[] = []; + + // Find all component files (excluding stories, tests, etc.) + const componentFiles = program.getSourceFiles() + .filter(sf => + !sf.isDeclarationFile && + sf.fileName.includes('/components/') && + !sf.fileName.includes('.stories.') && + !sf.fileName.includes('.test.') && + !sf.fileName.includes('View.ts') && + sf.fileName.endsWith('.ts') + ); + + componentFiles.forEach(sourceFile => { + const componentInfo = extractComponentInfo(sourceFile, checker); + if (componentInfo) { + components.push(componentInfo); + } + }); + + const raisinsDoc: Package = { + schemaVersion: '1.0.0', + modules: [ + { + tags: components, + }, + ], + }; + + // Ensure output directory exists + const outDir = path.dirname(outFile); + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + fs.writeFileSync(outFile, JSON.stringify(raisinsDoc, null, 2)); + console.log(`✅ Generated raisins.json with ${components.length} components`); +} + +// Run the generator +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const srcDir = path.resolve(__dirname, '../src'); +const outFile = path.resolve(__dirname, '../dist/docs/raisins.json'); + +try { + generateRaisins(srcDir, outFile); +} catch (error) { + console.error('❌ Error generating raisins.json:', error); + process.exit(1); +} diff --git a/packages/lit-components/src/components/BankingInfoForm/BankingInfoForm.stories.ts b/packages/lit-components/src/components/BankingInfoForm/BankingInfoForm.stories.ts new file mode 100644 index 0000000000..edaa472feb --- /dev/null +++ b/packages/lit-components/src/components/BankingInfoForm/BankingInfoForm.stories.ts @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { BankingInfoFormView } from './BankingInfoFormView'; + +const meta: Meta = { + title: 'Components/BankingInfoForm', + component: 'sql-banking-info-form', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Banking Information', + submitLabel: 'Save', + accountNameLabel: 'Account Holder Name', + bankNameLabel: 'Bank Name', + accountNumberLabel: 'Account Number', + routingNumberLabel: 'Routing Number', + programId: 'demo-program', + accountName: 'Jane Doe', + setAccountName: () => undefined, + bankName: 'Mint Bank', + setBankName: () => undefined, + accountNumber: '****1234', + setAccountNumber: () => undefined, + routingNumber: '110000000', + setRoutingNumber: () => undefined, + error: '', + loading: false, + success: false, + onSubmit: (event: Event) => event.preventDefault(), +}; + +export const Default: Story = { + render: () => BankingInfoFormView(baseProps), +}; + +export const WithError: Story = { + render: () => + BankingInfoFormView({ + ...baseProps, + error: 'All fields are required', + }), +}; + +export const Success: Story = { + render: () => + BankingInfoFormView({ + ...baseProps, + success: true, + }), +}; + +export const Loading: Story = { + render: () => + BankingInfoFormView({ + ...baseProps, + loading: true, + }), +}; diff --git a/packages/lit-components/src/components/BankingInfoForm/BankingInfoForm.ts b/packages/lit-components/src/components/BankingInfoForm/BankingInfoForm.ts new file mode 100644 index 0000000000..7230408594 --- /dev/null +++ b/packages/lit-components/src/components/BankingInfoForm/BankingInfoForm.ts @@ -0,0 +1,53 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { BankingInfoFormView } from './BankingInfoFormView'; +import { useDemoBankingInfoForm, useBankingInfoForm } from './useBankingInfoForm'; + +export interface BankingInfoFormProps { + headerText: string; + submitLabel: string; + accountNameLabel: string; + bankNameLabel: string; + accountNumberLabel: string; + routingNumberLabel: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-banking-info-form': HTMLElement; + } +} + +export const BankingInfoForm = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: BankingInfoFormProps = { + headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Banking Information', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Save', + accountNameLabel: + typeof rawProps.accountNameLabel === 'string' ? rawProps.accountNameLabel : 'Account Holder Name', + bankNameLabel: typeof rawProps.bankNameLabel === 'string' ? rawProps.bankNameLabel : 'Bank Name', + accountNumberLabel: + typeof rawProps.accountNumberLabel === 'string' ? rawProps.accountNumberLabel : 'Account Number', + routingNumberLabel: + typeof rawProps.routingNumberLabel === 'string' ? rawProps.routingNumberLabel : 'Routing Number', + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + }; + + const hookProps = isDemo() ? useDemoBankingInfoForm(props) : useBankingInfoForm(props); + + return BankingInfoFormView({ ...props, ...hookProps }); + }, + 'sql-banking-info-form', + [ + 'header-text', + 'submit-label', + 'account-name-label', + 'bank-name-label', + 'account-number-label', + 'routing-number-label', + 'program-id', + ] as const +); diff --git a/packages/lit-components/src/components/BankingInfoForm/BankingInfoFormView.ts b/packages/lit-components/src/components/BankingInfoForm/BankingInfoFormView.ts new file mode 100644 index 0000000000..5aa965cfb0 --- /dev/null +++ b/packages/lit-components/src/components/BankingInfoForm/BankingInfoFormView.ts @@ -0,0 +1,47 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { BankingInfoFormProps } from './BankingInfoForm'; +import { useBankingInfoForm } from './useBankingInfoForm'; + +const getInputValue = (event: Event) => ((event.target as HTMLInputElement & { value: string })?.value ?? ''); + +export function BankingInfoFormView(props: BankingInfoFormProps & ReturnType) { + return html` + +
+

${props.headerText}

+ ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} + ${props.success ? html`${UI.Alert({ variant: 'success', open: true, children: 'Banking information saved.' })}` : ''} +
+ ${UI.Input({ label: props.accountNameLabel, value: props.accountName, onInput: (event: Event) => props.setAccountName(getInputValue(event)), disabled: props.loading })} + ${UI.Input({ label: props.bankNameLabel, value: props.bankName, onInput: (event: Event) => props.setBankName(getInputValue(event)), disabled: props.loading })} + ${UI.Input({ label: props.accountNumberLabel, value: props.accountNumber, onInput: (event: Event) => props.setAccountNumber(getInputValue(event)), disabled: props.loading })} + ${UI.Input({ label: props.routingNumberLabel, value: props.routingNumber, onInput: (event: Event) => props.setRoutingNumber(getInputValue(event)), disabled: props.loading })} + ${UI.Button({ type: 'submit', variant: 'primary', loading: props.loading, children: props.submitLabel })} +
+
+ `; +} diff --git a/packages/lit-components/src/components/BankingInfoForm/index.ts b/packages/lit-components/src/components/BankingInfoForm/index.ts new file mode 100644 index 0000000000..494432d758 --- /dev/null +++ b/packages/lit-components/src/components/BankingInfoForm/index.ts @@ -0,0 +1 @@ +export * from './BankingInfoForm'; diff --git a/packages/lit-components/src/components/BankingInfoForm/useBankingInfoForm.test.ts b/packages/lit-components/src/components/BankingInfoForm/useBankingInfoForm.test.ts new file mode 100644 index 0000000000..054fe59964 --- /dev/null +++ b/packages/lit-components/src/components/BankingInfoForm/useBankingInfoForm.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useMutation: vi.fn(() => [vi.fn().mockResolvedValue({ success: true })]), +})); + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useBankingInfoForm } from './useBankingInfoForm'; + +describe('useBankingInfoForm', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + componentBoilerplateMock.useProgramId.mockReturnValue('test-program'); + componentBoilerplateMock.useMutation.mockReturnValue([vi.fn().mockResolvedValue({ success: true })]); + }); + + it('should return initial state', () => { + const result = useBankingInfoForm({ programId: '' } as any); + + expect(result.accountName).toBe(''); + expect(result.bankName).toBe(''); + expect(result.accountNumber).toBe(''); + expect(result.routingNumber).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + expect(result.success).toBe(false); + }); + + it('should validate empty fields', async () => { + const submitBanking = vi.fn(); + componentBoilerplateMock.useMutation.mockReturnValue([submitBanking]); + const result = useBankingInfoForm({ programId: '' } as any); + const event = createEvent(); + + await result.onSubmit(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(universalHooksMock.setters[4]).toHaveBeenCalledWith('All fields are required'); + expect(submitBanking).not.toHaveBeenCalled(); + }); + + it('should submit banking info with the resolved program id', async () => { + universalHooksMock.values.push('Ada Lovelace', 'Bank of Tests', '123456789', '987654321'); + const submitBanking = vi.fn().mockResolvedValue({ success: true }); + componentBoilerplateMock.useMutation.mockReturnValue([submitBanking]); + const result = useBankingInfoForm({ programId: 'fallback-program' } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[5]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[4]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[6]).toHaveBeenNthCalledWith(1, false); + expect(submitBanking).toHaveBeenCalledWith({ + bankingInfo: { + accountName: 'Ada Lovelace', + bankName: 'Bank of Tests', + accountNumber: '123456789', + routingNumber: '987654321', + }, + programId: 'test-program', + }); + expect(universalHooksMock.setters[6]).toHaveBeenNthCalledWith(2, true); + expect(universalHooksMock.setters[5]).toHaveBeenNthCalledWith(2, false); + }); +}); diff --git a/packages/lit-components/src/components/BankingInfoForm/useBankingInfoForm.ts b/packages/lit-components/src/components/BankingInfoForm/useBankingInfoForm.ts new file mode 100644 index 0000000000..ed51ef31ca --- /dev/null +++ b/packages/lit-components/src/components/BankingInfoForm/useBankingInfoForm.ts @@ -0,0 +1,93 @@ +import { useMutation, useProgramId } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { BankingInfoFormProps } from './BankingInfoForm'; + +const SUBMIT_BANKING = gql` + mutation submitBankingInfo($bankingInfo: BankingInfoInput!, $programId: ID) { + submitBankingInfo(bankingInfo: $bankingInfo, programId: $programId) { + success + } + } +`; + +export function useBankingInfoForm(props: BankingInfoFormProps) { + const programId = useProgramId() || props.programId; + const [submitBanking] = useMutation(SUBMIT_BANKING); + const [accountName, setAccountName] = useState(''); + const [bankName, setBankName] = useState(''); + const [accountNumber, setAccountNumber] = useState(''); + const [routingNumber, setRoutingNumber] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + async function onSubmit(e: Event) { + e.preventDefault(); + if (!accountName || !bankName || !accountNumber || !routingNumber) { + setError('All fields are required'); + return; + } + setLoading(true); + setError(''); + setSuccess(false); + try { + await submitBanking({ + bankingInfo: { accountName, bankName, accountNumber, routingNumber }, + programId, + }); + setSuccess(true); + } catch { + setError('Failed to save banking information. Please try again.'); + } + setLoading(false); + } + + return { + accountName, + setAccountName, + bankName, + setBankName, + accountNumber, + setAccountNumber, + routingNumber, + setRoutingNumber, + error, + loading, + success, + onSubmit, + }; +} + +export function useDemoBankingInfoForm( + _props: BankingInfoFormProps +): ReturnType { + const [accountName, setAccountName] = useState('Jane Doe'); + const [bankName, setBankName] = useState('Demo Bank'); + const [accountNumber, setAccountNumber] = useState('****1234'); + const [routingNumber, setRoutingNumber] = useState('110000000'); + const [error, setError] = useState(''); + const [loading] = useState(false); + const [success, setSuccess] = useState(false); + + async function onSubmit(event: Event) { + event.preventDefault(); + setError(''); + setSuccess(true); + } + + return { + accountName, + setAccountName, + bankName, + setBankName, + accountNumber, + setAccountNumber, + routingNumber, + setRoutingNumber, + error, + loading, + success, + onSubmit, + }; +} diff --git a/packages/lit-components/src/components/BaseRegistration/BaseRegistration.stories.ts b/packages/lit-components/src/components/BaseRegistration/BaseRegistration.stories.ts new file mode 100644 index 0000000000..218cf0eabd --- /dev/null +++ b/packages/lit-components/src/components/BaseRegistration/BaseRegistration.stories.ts @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { UI } from '../../ui'; +import '../../index'; +import { BaseRegistrationView } from './BaseRegistrationView'; + +const meta: Meta = { + title: 'Components/BaseRegistration', + component: 'sql-base-registration', + tags: ['autodocs'], + argTypes: { + 'page-label': { control: 'text' }, + 'email-label': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'required-field-error-message': { control: 'text' }, + 'invalid-email-error-message': { control: 'text' }, + 'show-google-button': { control: 'boolean' }, + 'show-secondary-button': { control: 'boolean' }, + }, + render: (args) => html` + + ${UI.Button({ slot: 'googleButton', variant: 'default', children: 'Register with Google' })} +
+ Already have an account? + ${UI.Button({ variant: 'text', children: 'Sign in' })} +
+
+ `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + pageLabel: 'Register', + emailLabel: 'Email', + submitLabel: 'Register', + requiredFieldErrorMessage: 'Cannot be empty', + invalidEmailErrorMessage: 'Must be a valid email address', + showGoogleButton: true, + showSecondaryButton: true, + email: '', + setEmail: (_value: string) => undefined, + validationErrors: {}, + loading: false, + onSubmit: async (event: Event) => event.preventDefault(), +}; + +export const Default: Story = {}; + +export const EmailError: Story = { + render: () => + BaseRegistrationView({ + ...baseProps, + validationErrors: { + email: 'Must be a valid email address', + }, + }), +}; + +export const EmailOnly: Story = { + args: { + 'show-google-button': false, + 'show-secondary-button': false, + }, +}; diff --git a/packages/lit-components/src/components/BaseRegistration/BaseRegistration.ts b/packages/lit-components/src/components/BaseRegistration/BaseRegistration.ts new file mode 100644 index 0000000000..ca4bd60d9f --- /dev/null +++ b/packages/lit-components/src/components/BaseRegistration/BaseRegistration.ts @@ -0,0 +1,68 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { BaseRegistrationView } from './BaseRegistrationView'; +import { useBaseRegistration } from './useBaseRegistration'; + +export interface BaseRegistrationProps { + pageLabel: string; + emailLabel: string; + submitLabel: string; + requiredFieldErrorMessage: string; + invalidEmailErrorMessage: string; + showGoogleButton: boolean; + showSecondaryButton: boolean; +} + +const parseBoolean = (value: unknown, fallback: boolean) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-base-registration': HTMLElement; + } +} + +export const BaseRegistration = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: BaseRegistrationProps = { + pageLabel: typeof rawProps.pageLabel === 'string' ? rawProps.pageLabel : 'Register', + emailLabel: typeof rawProps.emailLabel === 'string' ? rawProps.emailLabel : 'Email', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Register', + requiredFieldErrorMessage: + typeof rawProps.requiredFieldErrorMessage === 'string' + ? rawProps.requiredFieldErrorMessage + : 'Cannot be empty', + invalidEmailErrorMessage: + typeof rawProps.invalidEmailErrorMessage === 'string' + ? rawProps.invalidEmailErrorMessage + : 'Must be a valid email address', + showGoogleButton: parseBoolean(rawProps.showGoogleButton, true), + showSecondaryButton: parseBoolean(rawProps.showSecondaryButton, true), + }; + + const hookProps = useBaseRegistration(props); + + return BaseRegistrationView({ + ...props, + ...hookProps, + formData: host.querySelector('[slot="formData"]') ? undefined : undefined, + googleButton: props.showGoogleButton ? undefined : undefined, + secondaryButton: props.showSecondaryButton ? undefined : undefined, + terms: undefined, + }); + }, + 'sql-base-registration', + [ + 'page-label', + 'email-label', + 'submit-label', + 'required-field-error-message', + 'invalid-email-error-message', + 'show-google-button', + 'show-secondary-button', + ] as const +); diff --git a/packages/lit-components/src/components/BaseRegistration/BaseRegistrationView.ts b/packages/lit-components/src/components/BaseRegistration/BaseRegistrationView.ts new file mode 100644 index 0000000000..b3a083a5c5 --- /dev/null +++ b/packages/lit-components/src/components/BaseRegistration/BaseRegistrationView.ts @@ -0,0 +1,109 @@ +import { html, nothing, type TemplateResult } from 'lit'; +import { UI } from '../../ui'; +import type { BaseRegistrationProps } from './BaseRegistration'; +import type { BaseRegistrationHookResult } from './useBaseRegistration'; + +export interface BaseRegistrationViewProps + extends BaseRegistrationProps, + BaseRegistrationHookResult { + formData?: TemplateResult; + terms?: TemplateResult; + googleButton?: TemplateResult; + secondaryButton?: TemplateResult; +} + +const getValue = (event: Event) => + String((event.target as HTMLInputElement & { value?: string })?.value ?? ''); + +export function BaseRegistrationView(props: BaseRegistrationViewProps) { + const hasSecondaryActions = + (props.showGoogleButton && Boolean(props.googleButton)) || + (props.showSecondaryButton && Boolean(props.secondaryButton)); + + return html` + +
+

${props.pageLabel}

+
+ ${props.formData ?? html``} +
+ ${UI.Input({ + type: 'email', + name: 'email', + label: props.emailLabel, + value: props.email, + disabled: props.loading, + onInput: (event: Event) => props.setEmail(getValue(event)), + })} + ${props.validationErrors.email + ? html`

${props.validationErrors.email}

` + : nothing} +
+ ${props.terms ?? html``} +
+ ${UI.Button({ type: 'submit', variant: 'primary', loading: props.loading, children: props.submitLabel })} + ${hasSecondaryActions + ? html`
or
` + : nothing} + ${props.showGoogleButton + ? props.googleButton ?? html`` + : nothing} + ${props.showSecondaryButton + ? props.secondaryButton ?? html`` + : nothing} +
+
+
+ `; +} diff --git a/packages/lit-components/src/components/BaseRegistration/index.ts b/packages/lit-components/src/components/BaseRegistration/index.ts new file mode 100644 index 0000000000..bbd0acd470 --- /dev/null +++ b/packages/lit-components/src/components/BaseRegistration/index.ts @@ -0,0 +1 @@ +export * from './BaseRegistration'; diff --git a/packages/lit-components/src/components/BaseRegistration/useBaseRegistration.test.ts b/packages/lit-components/src/components/BaseRegistration/useBaseRegistration.test.ts new file mode 100644 index 0000000000..02a73dc6d9 --- /dev/null +++ b/packages/lit-components/src/components/BaseRegistration/useBaseRegistration.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useBaseRegistration } from './useBaseRegistration'; + +describe('useBaseRegistration', () => { + const props = { + requiredFieldErrorMessage: 'Email is required', + invalidEmailErrorMessage: 'Please enter a valid email', + } as const; + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = useBaseRegistration(props as any); + + expect(result.email).toBe(''); + expect(result.validationErrors).toEqual({}); + expect(result.loading).toBe(false); + }); + + it('should validate an empty email', async () => { + const result = useBaseRegistration(props as any); + const event = createEvent(); + + await result.onSubmit(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith({ email: 'Email is required' }); + }); + + it('should validate an invalid email', async () => { + universalHooksMock.values.push('invalid-email'); + const result = useBaseRegistration(props as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith({ + email: 'Please enter a valid email', + }); + }); + + it('should dispatch the submit event for a valid email', async () => { + universalHooksMock.values.push('user@example.com'); + const dispatchSpy = document.dispatchEvent as ReturnType; + const result = useBaseRegistration(props as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith({}); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:base-registration-submit'); + expect(event.detail).toEqual({ email: 'user@example.com' }); + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(2, false); + }); +}); diff --git a/packages/lit-components/src/components/BaseRegistration/useBaseRegistration.ts b/packages/lit-components/src/components/BaseRegistration/useBaseRegistration.ts new file mode 100644 index 0000000000..fbdba76d5d --- /dev/null +++ b/packages/lit-components/src/components/BaseRegistration/useBaseRegistration.ts @@ -0,0 +1,43 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { BaseRegistrationProps } from './BaseRegistration'; + +export interface BaseRegistrationHookResult { + email: string; + setEmail: (value: string) => void; + validationErrors: Record; + loading: boolean; + onSubmit: (event: Event) => Promise; +} + +export function useBaseRegistration(props: BaseRegistrationProps): BaseRegistrationHookResult { + const [email, setEmail] = useState(''); + const [validationErrors, setValidationErrors] = useState>({}); + const [loading, setLoading] = useState(false); + + async function onSubmit(event: Event) { + event.preventDefault(); + + if (!email) { + setValidationErrors({ email: props.requiredFieldErrorMessage }); + return; + } + + if (!email.includes('@')) { + setValidationErrors({ email: props.invalidEmailErrorMessage }); + return; + } + + setLoading(true); + setValidationErrors({}); + document.dispatchEvent( + new CustomEvent('sq:base-registration-submit', { + bubbles: true, + composed: true, + detail: { email }, + }) + ); + setLoading(false); + } + + return { email, setEmail, validationErrors, loading, onSubmit }; +} diff --git a/packages/lit-components/src/components/BigStat/BigStat.stories.ts b/packages/lit-components/src/components/BigStat/BigStat.stories.ts new file mode 100644 index 0000000000..e8ebac94fc --- /dev/null +++ b/packages/lit-components/src/components/BigStat/BigStat.stories.ts @@ -0,0 +1,122 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { BigStatView } from './BigStatView'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IklSTVhzWXk2WVlxcTQ2OTQzN21HOEVSUXQ4UW9LRkJhRzEifQ.eyJ1c2VyIjp7ImlkIjoidGVzdHVzZXIiLCJhY2NvdW50SWQiOiJ0ZXN0dXNlciIsImVtYWlsIjoidGVzdHVzZXJAZXhhbXBsZS5jb20ifX0.tegzTaLms4g47rwcWoyhk1WW4hqB16PulQV9zouJNfU'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IklSTVhzWXk2WVlxcTQ2OTQzN21HOEVSUXQ4UW9LRkJhRzEifQ.eyJ1c2VyIjp7ImlkIjoidGVzdHVzZXIiLCJhY2NvdW50SWQiOiJ0ZXN0dXNlciIsImVtYWlsIjoidGVzdHVzZXJAZXhhbXBsZS5jb20ifX0.tegzTaLms4g47rwcWoyhk1WW4hqB16PulQV9zouJNfU', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/BigStat', + component: 'sql-big-stat', + tags: ['autodocs'], + argTypes: { + 'stat-type': { control: 'text' }, + 'flex-reverse': { control: 'boolean' }, + alignment: { control: 'select', options: ['left', 'center', 'right'] }, + 'program-id': { control: 'text' }, + 'stat-text-color': { control: 'color' }, + 'stat-font-size': { control: 'number' }, + 'stat-font-weight': { control: 'number' }, + 'description-text-color': { control: 'color' }, + 'description-font-size': { control: 'number' }, + content: { control: 'text' }, + }, + render: (args) => { + setupGlobals(); + return html` +

${args.content || 'Referrals'}

+
`; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + 'stat-type': '/referralsCount', + alignment: 'center', + content: 'Referrals', + }, +}; + +export const LeftAligned: Story = { + args: { + alignment: 'left', + content: 'Left aligned stat', + }, +}; + +export const RightAligned: Story = { + args: { + alignment: 'right', + content: 'Right aligned stat', + }, +}; + +export const CustomColors: Story = { + args: { + 'stat-text-color': '#1ed760', + 'description-text-color': '#121212', + content: 'Custom colors', + }, +}; + +export const CustomFontSizes: Story = { + args: { + 'stat-font-size': 48, + 'stat-font-weight': 600, + 'description-font-size': 18, + content: 'Custom font sizes', + }, +}; + +export const FlexReverse: Story = { + args: { + 'flex-reverse': true, + content: 'Flex reverse', + }, +}; + +export const LoadingState: Story = { + render: () => + BigStatView({ + statvalue: '...', + value: 0, + loading: true, + label: 'Loading state', + alignment: 'center', + flexReverse: false, + }), +}; diff --git a/packages/lit-components/src/components/BigStat/BigStat.ts b/packages/lit-components/src/components/BigStat/BigStat.ts new file mode 100644 index 0000000000..851c96c6fd --- /dev/null +++ b/packages/lit-components/src/components/BigStat/BigStat.ts @@ -0,0 +1,72 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { BigStatView } from './BigStatView'; +import { useBigStat, useDemoBigStat } from './useBigStat'; + +export interface BigStatProps { + statType?: string; + flexReverse?: boolean; + alignment?: 'left' | 'right' | 'center'; + programId?: string; + statTextColor?: string; + statFontSize?: number; + statFontWeight?: number; + descriptionTextColor?: string; + descriptionFontSize?: number; +} + +const parseBoolean = (value: unknown, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +const parseNumber = (value: unknown) => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim() !== '') return Number(value); + return undefined; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-big-stat': HTMLElement; + } +} + +export const BigStat = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: BigStatProps = { + statType: typeof rawProps.statType === 'string' ? rawProps.statType : '', + flexReverse: parseBoolean(rawProps.flexReverse, false), + alignment: + rawProps.alignment === 'left' || rawProps.alignment === 'right' || rawProps.alignment === 'center' + ? rawProps.alignment + : 'center', + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + statTextColor: typeof rawProps.statTextColor === 'string' ? rawProps.statTextColor : undefined, + statFontSize: parseNumber(rawProps.statFontSize), + statFontWeight: parseNumber(rawProps.statFontWeight), + descriptionTextColor: + typeof rawProps.descriptionTextColor === 'string' ? rawProps.descriptionTextColor : undefined, + descriptionFontSize: parseNumber(rawProps.descriptionFontSize), + }; + + const hookProps = isDemo() ? useDemoBigStat(props) : useBigStat(props); + + return BigStatView({ ...props, ...hookProps }); + }, + 'sql-big-stat', + [ + 'stat-type', + 'flex-reverse', + 'alignment', + 'program-id', + 'stat-text-color', + 'stat-font-size', + 'stat-font-weight', + 'description-text-color', + 'description-font-size', + ] as const +); diff --git a/packages/lit-components/src/components/BigStat/BigStatView.ts b/packages/lit-components/src/components/BigStat/BigStatView.ts new file mode 100644 index 0000000000..8f7be0d4d0 --- /dev/null +++ b/packages/lit-components/src/components/BigStat/BigStatView.ts @@ -0,0 +1,45 @@ +import { html } from 'lit'; +import { BigStatProps } from './BigStat'; +import { BigStatHookResult } from './useBigStat'; + +export function BigStatView(props: BigStatProps & BigStatHookResult) { + const alignment = props.alignment || 'center'; + const alignItems = + alignment === 'left' ? 'flex-start' : alignment === 'right' ? 'flex-end' : 'center'; + + return html` + +
+
${props.loading ? '...' : props.statvalue}
+
${props.label}
+
+ `; +} diff --git a/packages/lit-components/src/components/BigStat/index.ts b/packages/lit-components/src/components/BigStat/index.ts new file mode 100644 index 0000000000..d7586c37e7 --- /dev/null +++ b/packages/lit-components/src/components/BigStat/index.ts @@ -0,0 +1 @@ +export * from './BigStat'; diff --git a/packages/lit-components/src/components/BigStat/useBigStat.test.ts b/packages/lit-components/src/components/BigStat/useBigStat.test.ts new file mode 100644 index 0000000000..98b3469ebc --- /dev/null +++ b/packages/lit-components/src/components/BigStat/useBigStat.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + isDemo: vi.fn(() => false), + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt', id: 'user1', accountId: 'acc1' })), + useQuery: vi.fn(() => ({ + data: { + viewer: { + referrals: { totalCount: 5 }, + rewards: { totalCount: 10 }, + customFields: { myField: '42' }, + programGoals: [{ goalId: 'g1', programId: 'test-program', count: 7, conversionCount: 3 }], + rewardBalanceDetails: [{ prettyAvailableValue: '$25.00' }], + }, + }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useBigStat } from './useBigStat'; + +describe('useBigStat', () => { + beforeEach(() => { + componentBoilerplateMock.isDemo.mockReturnValue(false); + componentBoilerplateMock.useProgramId.mockReturnValue('test-program'); + componentBoilerplateMock.useUserIdentity.mockReturnValue({ jwt: 'test-jwt', id: 'user1', accountId: 'acc1' }); + componentBoilerplateMock.useQuery.mockClear(); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + viewer: { + referrals: { totalCount: 5 }, + rewards: { totalCount: 10 }, + customFields: { myField: '42' }, + programGoals: [{ goalId: 'g1', programId: 'test-program', count: 7, conversionCount: 3 }], + rewardBalanceDetails: [{ prettyAvailableValue: '$25.00' }], + }, + }, + loading: false, + }); + }); + + it('returns the default result for invalid or empty stat types', () => { + expect(useBigStat({ statType: '' } as any)).toEqual({ + value: 0, + statvalue: '-', + loading: false, + label: 'BAD PROP TYPE', + }); + + expect(useBigStat({ statType: '/not-a-stat' } as any)).toEqual({ + value: 0, + statvalue: '-', + loading: false, + label: 'BAD PROP TYPE', + }); + }); + + it('returns the referrals count stat', () => { + const result = useBigStat({ statType: '/referralsCount' } as any); + + expect(result.label).toBe('Referrals - Count'); + expect(result.value).toBe(5); + expect(result.statvalue).toBe('5'); + }); + + it('returns the rewards count stat', () => { + const result = useBigStat({ statType: '/rewardsCount' } as any); + + expect(result.label).toBe('Rewards'); + expect(result.value).toBe(10); + expect(result.statvalue).toBe('10'); + }); + + it('returns the custom field stat', () => { + const result = useBigStat({ statType: '/customFields/myField' } as any); + + expect(result.value).toBe(42); + expect(result.statvalue).toBe('42'); + }); + + it('returns the program goal stat', () => { + const result = useBigStat({ statType: '/programGoals/count/g1' } as any); + + expect(result.value).toBe(7); + expect(result.statvalue).toBe('7'); + }); + + it('returns the reward balance stat', () => { + const result = useBigStat({ statType: '/rewardBalance/CREDIT/POINT/prettyValue' } as any); + + expect(result.value).toBe(25); + expect(result.statvalue).toBe('$25.00'); + }); + + it('shows ellipsis while loading', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + viewer: { + referrals: { totalCount: 5 }, + }, + }, + loading: true, + }); + + const result = useBigStat({ statType: '/referralsCount' } as any); + + expect(result.loading).toBe(true); + expect(result.statvalue).toBe('...'); + }); + + it('skips the query when there is no user jwt', () => { + componentBoilerplateMock.useUserIdentity.mockReturnValue({ jwt: '', id: 'user1', accountId: 'acc1' }); + + useBigStat({ statType: '/referralsCount' } as any); + + expect(componentBoilerplateMock.useQuery).toHaveBeenCalledWith(expect.anything(), expect.anything(), true); + }); +}); diff --git a/packages/lit-components/src/components/BigStat/useBigStat.ts b/packages/lit-components/src/components/BigStat/useBigStat.ts new file mode 100644 index 0000000000..8a975defad --- /dev/null +++ b/packages/lit-components/src/components/BigStat/useBigStat.ts @@ -0,0 +1,332 @@ +import { isDemo, useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { BigStatProps } from './BigStat'; + +export interface BigStatHookResult { + value: number; + statvalue: string; + loading: boolean; + label: string; +} + +type BigStatRoute = + | { name: 'referralsCount'; label: string; status?: string } + | { name: 'referralsMonth'; label: string; status?: string } + | { name: 'referralsWeek'; label: string; status?: string } + | { name: 'rewardsCount'; label: string; filter?: string } + | { name: 'rewardsMonth'; label: string; filter?: string } + | { name: 'rewardsWeek'; label: string; filter?: string } + | { + name: 'rewardsCountFiltered'; + label: string; + rewardType?: string; + rewardUnit?: string; + rewardStatus?: string; + filter?: string; + } + | { + name: 'integrationRewardsCountFiltered'; + label: string; + rewardStatus?: string; + filter?: string; + } + | { + name: 'rewardBalance'; + label: string; + rewardType?: string; + rewardUnit?: string; + valueFormat?: 'prettyValue' | 'value'; + filter?: string; + } + | { name: 'customFields'; label: string; fieldName: string } + | { name: 'programGoals'; label: string; metricType: string; goalId: string }; + +interface BigStatQueryResult { + viewer?: { + referrals?: { totalCount?: number | null } | null; + rewards?: { totalCount?: number | null } | null; + customFields?: Record | null; + programGoals?: Array<{ + goalId?: string | null; + programId?: string | null; + count?: number | null; + conversionCount?: number | null; + } | null> | null; + rewardBalanceDetails?: Array<{ + prettyAvailableValue?: string | null; + } | null> | null; + } | null; +} + +const BIG_STAT_QUERY = gql` + query bigStat( + $programId: ID + $referralFilter: ReferralFilterInput + $rewardFilter: RewardFilterInput + $rewardType: RewardType + $rewardUnit: String + $rewardValueFormat: RewardValueFormatType! + ) { + viewer { + ... on User { + referrals(filter: $referralFilter) { + totalCount + } + rewards(filter: $rewardFilter) { + totalCount + } + customFields + programGoals { + goalId + programId + count + conversionCount + } + rewardBalanceDetails( + programId: $programId + filter: { type_eq: $rewardType, unit_eq: $rewardUnit } + ) { + ... on CreditRewardBalance { + prettyAvailableValue(formatType: $rewardValueFormat) + } + } + } + } + } +`; + +const DEFAULT_RESULT: BigStatHookResult = { + value: 0, + statvalue: '-', + loading: false, + label: 'BAD PROP TYPE', +}; + +function parseStatType(statType?: string): BigStatRoute | null { + const value = statType?.trim(); + if (!value) return null; + + let match = value.match(/^\/referralsCount(?:\/(started|converted))?$/); + if (match) return { name: 'referralsCount', label: 'Referrals - Count', status: match[1] }; + + match = value.match(/^\/referralsMonth(?:\/(started|converted))?$/); + if (match) return { name: 'referralsMonth', label: 'Referrals - This Month', status: match[1] }; + + match = value.match(/^\/referralsWeek(?:\/(started|converted))?$/); + if (match) return { name: 'referralsWeek', label: 'Referrals - This Week', status: match[1] }; + + match = value.match(/^\/rewardsCount(?:\/(global))?$/); + if (match) return { name: 'rewardsCount', label: 'Rewards', filter: match[1] }; + + match = value.match(/^\/rewardsMonth(?:\/(global))?$/); + if (match) return { name: 'rewardsMonth', label: 'Rewards - This Month', filter: match[1] }; + + match = value.match(/^\/rewardsWeek(?:\/(global))?$/); + if (match) return { name: 'rewardsWeek', label: 'Rewards - This Week', filter: match[1] }; + + match = value.match( + /^\/rewardsCountFiltered(?:\/([^/]+))?(?:\/([^/]+))?(?:\/(PENDING|CANCELLED|EXPIRED|REDEEMED|AVAILABLE))?(?:\/(global))?$/ + ); + if (match) { + return { + name: 'rewardsCountFiltered', + label: 'Rewards', + rewardType: match[1], + rewardUnit: match[2], + rewardStatus: match[3], + filter: match[4], + }; + } + + match = value.match( + /^\/integrationRewardsCountFiltered(?:\/(PENDING|CANCELLED|EXPIRED|REDEEMED|AVAILABLE))?(?:\/(global))?$/ + ); + if (match) { + return { + name: 'integrationRewardsCountFiltered', + label: 'Rewards', + rewardStatus: match[1], + filter: match[2], + }; + } + + match = value.match(/^\/rewardBalance\/([^/]+)\/([^/]+)(?:\/(prettyValue|value))?(?:\/(global))?$/); + if (match) { + return { + name: 'rewardBalance', + label: 'Reward Balance', + rewardType: match[1], + rewardUnit: match[2], + valueFormat: (match[3] as 'prettyValue' | 'value' | undefined) || 'prettyValue', + filter: match[4], + }; + } + + match = value.match(/^\/customFields\/([^/]+)$/); + if (match) return { name: 'customFields', label: 'Custom Fields', fieldName: decodeURIComponent(match[1]) }; + + match = value.match(/^\/programGoals\/([^/]+)\/([^/]+)$/); + if (match) { + return { + name: 'programGoals', + label: 'Program Goals', + metricType: decodeURIComponent(match[1]), + goalId: decodeURIComponent(match[2]), + }; + } + + return null; +} + +function getScopedProgramId(programId?: string, filter?: string) { + if (!programId || filter === 'global' || programId === 'classic') return null; + return programId; +} + +function getReferralFilter(route: BigStatRoute | null, programId?: string) { + if ( + !route || + (route.name !== 'referralsCount' && route.name !== 'referralsMonth' && route.name !== 'referralsWeek') + ) { + return undefined; + } + + return { + ...(programId && programId !== 'classic' ? { programId_eq: programId } : {}), + ...(route.status === 'converted' + ? { dateConverted_exists: true } + : route.status === 'started' + ? { dateConverted_exists: false } + : {}), + ...(route.name === 'referralsMonth' + ? { dateReferralStarted_timeframe: 'this_month' } + : route.name === 'referralsWeek' + ? { dateReferralStarted_timeframe: 'this_week' } + : {}), + }; +} + +function getRewardFilter(route: BigStatRoute | null, programId?: string) { + if (!route) return undefined; + + if (route.name === 'integrationRewardsCountFiltered') { + return { + ...(getScopedProgramId(programId, route.filter) ? { programId_eq: getScopedProgramId(programId, route.filter) } : {}), + type_eq: 'INTEGRATION', + ...(route.rewardStatus ? { statuses_eq: { status: route.rewardStatus } } : {}), + }; + } + + if (route.name === 'rewardsCount' || route.name === 'rewardsMonth' || route.name === 'rewardsWeek') { + return { + ...(getScopedProgramId(programId, route.filter) ? { programId_eq: getScopedProgramId(programId, route.filter) } : {}), + ...(route.name === 'rewardsMonth' + ? { dateGiven_timeframe: 'this_month' } + : route.name === 'rewardsWeek' + ? { dateGiven_timeframe: 'this_week' } + : {}), + }; + } + + if (route.name === 'rewardsCountFiltered') { + return { + ...(getScopedProgramId(programId, route.filter) ? { programId_eq: getScopedProgramId(programId, route.filter) } : {}), + ...(route.rewardType ? { type_eq: route.rewardType } : {}), + ...(route.rewardUnit ? { unit_eq: route.rewardUnit } : {}), + ...(route.rewardStatus ? { statuses_eq: { status: route.rewardStatus } } : {}), + }; + } + + return undefined; +} + +function getFormattedStat( + route: BigStatRoute, + data?: BigStatQueryResult, + programId?: string +): Pick { + if (route.name === 'customFields') { + const fieldValue = data?.viewer?.customFields?.[route.fieldName]; + return { value: Number(fieldValue) || 0, statvalue: fieldValue?.toString() || '0' }; + } + + if (route.name === 'programGoals') { + const goal = data?.viewer?.programGoals?.find( + (entry) => + entry?.goalId === route.goalId && (!programId || programId === 'classic' || entry?.programId === programId) + ); + const goalValue = route.metricType === 'conversionCount' ? goal?.conversionCount : goal?.count; + return { value: goalValue || 0, statvalue: goalValue?.toString() || '0' }; + } + + if (route.name === 'rewardBalance') { + const balanceText = data?.viewer?.rewardBalanceDetails?.[0]?.prettyAvailableValue || '0'; + const balanceValue = Number(balanceText.replace(/[^0-9.-]/g, '')) || 0; + return { value: balanceValue, statvalue: balanceText }; + } + + if ( + route.name === 'rewardsCount' || + route.name === 'rewardsMonth' || + route.name === 'rewardsWeek' || + route.name === 'rewardsCountFiltered' || + route.name === 'integrationRewardsCountFiltered' + ) { + const rewardCount = data?.viewer?.rewards?.totalCount || 0; + return { value: rewardCount, statvalue: rewardCount.toString() }; + } + + const referralCount = data?.viewer?.referrals?.totalCount || 0; + return { value: referralCount, statvalue: referralCount.toString() }; +} + +export function useBigStat(props: BigStatProps): BigStatHookResult { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const parsedRoute = parseStatType(props.statType); + + const { data, loading } = useQuery( + BIG_STAT_QUERY, + { + programId: getScopedProgramId(programId, parsedRoute && 'filter' in parsedRoute ? parsedRoute.filter : undefined), + referralFilter: getReferralFilter(parsedRoute, programId), + rewardFilter: getRewardFilter(parsedRoute, programId), + rewardType: + parsedRoute && 'rewardType' in parsedRoute && parsedRoute.rewardType ? parsedRoute.rewardType : 'CREDIT', + rewardUnit: + parsedRoute && 'rewardUnit' in parsedRoute && parsedRoute.rewardUnit ? parsedRoute.rewardUnit : 'POINT', + rewardValueFormat: + parsedRoute?.name === 'rewardBalance' && parsedRoute.valueFormat === 'value' + ? 'NUMBER_UNFORMATTED' + : 'UNIT_FORMATTED', + }, + !user?.jwt || !parsedRoute || isDemo() + ); + + if (!parsedRoute) return DEFAULT_RESULT; + + const stat = getFormattedStat(parsedRoute, data, programId); + + return { + value: stat.value, + statvalue: loading ? '...' : stat.statvalue, + loading, + label: parsedRoute.label, + }; +} + +export function useDemoBigStat(props: BigStatProps): BigStatHookResult { + const [demoValues] = useState({ + empty: 'Remember to...', + filled: 'Remember t...', + }); + const parsedRoute = parseStatType(props.statType); + + return { + value: 0, + statvalue: props.statType?.trim() ? demoValues.filled : demoValues.empty, + loading: false, + label: parsedRoute?.label || 'Demo Label', + }; +} diff --git a/packages/lit-components/src/components/Brand/Brand.stories.ts b/packages/lit-components/src/components/Brand/Brand.stories.ts new file mode 100644 index 0000000000..e66fd5131a --- /dev/null +++ b/packages/lit-components/src/components/Brand/Brand.stories.ts @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { UI } from '../../ui'; +import '../../index'; + +const meta: Meta = { + title: 'Components/Brand', + component: 'sql-brand', + tags: ['autodocs'], + argTypes: { + 'brand-color': { control: 'color' }, + 'brand-font': { control: 'text' }, + }, + render: (args) => html` + +
+

Brand your referral experience

+

Adjust the font family and primary brand color for slotted content.

+ ${UI.Button({ variant: 'primary', children: 'Primary action' })} + ${UI.Input({ label: 'Email address', placeholder: 'name@example.com' })} +
+
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomBrandColor: Story = { + args: { + 'brand-color': '#7c3aed', + }, +}; + +export const CustomFont: Story = { + args: { + 'brand-font': 'Roboto', + }, +}; + +export const BothCustomized: Story = { + args: { + 'brand-color': '#0f766e', + 'brand-font': 'Poppins', + }, +}; diff --git a/packages/lit-components/src/components/Brand/Brand.ts b/packages/lit-components/src/components/Brand/Brand.ts new file mode 100644 index 0000000000..eb53e326a2 --- /dev/null +++ b/packages/lit-components/src/components/Brand/Brand.ts @@ -0,0 +1,48 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { BrandView } from './BrandView'; +import { useBrand } from './useBrand'; + +export interface BrandProps { + /** + * Primary brand color. + * + * @uiName Brand color + * @uiWidget color + * @format color + */ + brandColor?: string; + + /** + * Google font family used throughout slotted content. + * + * @uiName Brand font + * @uiDefault "Nunito Sans" + */ + brandFont: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-brand': HTMLElement; + } +} + +/** + * @uiName Brand Container + * @slots [{"name":"","title":"Branded Content"}] + */ +export const Brand = useComponent( + (host) => { + const props: BrandProps = { + brandFont: 'Nunito Sans', + ...getProps(host), + }; + + const hookProps = useBrand(props); + + return BrandView({ ...props, ...hookProps }); + }, + 'sql-brand', + ['brand-color', 'brand-font'] as const +); diff --git a/packages/lit-components/src/components/Brand/BrandView.ts b/packages/lit-components/src/components/Brand/BrandView.ts new file mode 100644 index 0000000000..1d9280f61e --- /dev/null +++ b/packages/lit-components/src/components/Brand/BrandView.ts @@ -0,0 +1,26 @@ +import { html } from 'lit'; +import { BrandProps } from './Brand'; +import { UseBrandResult } from './useBrand'; + +export function BrandView(props: BrandProps & UseBrandResult) { + const font = props.font || 'Nunito Sans'; + + return html` + + + `; +} diff --git a/packages/lit-components/src/components/Brand/index.ts b/packages/lit-components/src/components/Brand/index.ts new file mode 100644 index 0000000000..731d929af6 --- /dev/null +++ b/packages/lit-components/src/components/Brand/index.ts @@ -0,0 +1 @@ +export * from './Brand'; diff --git a/packages/lit-components/src/components/Brand/useBrand.test.ts b/packages/lit-components/src/components/Brand/useBrand.test.ts new file mode 100644 index 0000000000..5b815dcab4 --- /dev/null +++ b/packages/lit-components/src/components/Brand/useBrand.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => ({ + useEffect: vi.fn((callback: () => void) => callback()), +})); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useEffect: universalHooksMock.useEffect, +})); + +import { useBrand } from './useBrand'; + +describe('useBrand', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + + const head = { + appendChild: vi.fn((node) => { + (node as { parentNode?: unknown }).parentNode = head; + return node; + }), + removeChild: vi.fn(), + }; + + vi.stubGlobal('document', { + head, + createElement: vi.fn(() => { + const attributes = new Map(); + return { + parentNode: null, + setAttribute: (name: string, value: string) => attributes.set(name, value), + getAttribute: (name: string) => attributes.get(name) ?? null, + }; + }), + }); + }); + + it('should return default brand values', () => { + const result = useBrand({} as any); + const appendSpy = document.head.appendChild as ReturnType; + + expect(result.font).toBe('Nunito Sans'); + expect(result.brandColorCss).toBe(''); + expect(appendSpy).toHaveBeenCalledTimes(1); + const link = appendSpy.mock.calls[0][0] as { getAttribute: (name: string) => string | null }; + expect(link.getAttribute('rel')).toBe('stylesheet'); + expect(link.getAttribute('href')).toContain('https://fonts.googleapis.com/css2?family=Nunito%20Sans'); + }); + + it('should return custom brand values', () => { + const result = useBrand({ brandFont: 'Roboto', brandColor: '#4CAF50' } as any); + + expect(result.font).toBe('Roboto'); + expect(result.brandColorCss).toContain('--sql-brand-color: #4CAF50;'); + }); +}); diff --git a/packages/lit-components/src/components/Brand/useBrand.ts b/packages/lit-components/src/components/Brand/useBrand.ts new file mode 100644 index 0000000000..5677d59ae5 --- /dev/null +++ b/packages/lit-components/src/components/Brand/useBrand.ts @@ -0,0 +1,58 @@ +import { useEffect } from '@saasquatch/universal-hooks'; +import { BrandProps } from './Brand'; + +export interface UseBrandResult { + font: string; + brandColorCss: string; +} + +function buildFontsCssUrl(font: string): string { + return `https://fonts.googleapis.com/css2?family=${encodeURIComponent(font)}`; +} + +function escapeCssString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function getBrandColorCss(brandColor?: string): string { + if (!brandColor) { + return ''; + } + + return ` + --sql-brand-color: ${brandColor}; + --sl-color-primary-50: color-mix(in srgb, var(--sql-brand-color) 8%, white); + --sl-color-primary-100: color-mix(in srgb, var(--sql-brand-color) 16%, white); + --sl-color-primary-200: color-mix(in srgb, var(--sql-brand-color) 28%, white); + --sl-color-primary-300: color-mix(in srgb, var(--sql-brand-color) 44%, white); + --sl-color-primary-400: color-mix(in srgb, var(--sql-brand-color) 70%, white); + --sl-color-primary-500: var(--sql-brand-color); + --sl-color-primary-600: color-mix(in srgb, var(--sql-brand-color) 88%, black); + --sl-color-primary-700: color-mix(in srgb, var(--sql-brand-color) 72%, black); + --sl-color-primary-800: color-mix(in srgb, var(--sql-brand-color) 56%, black); + --sl-color-primary-900: color-mix(in srgb, var(--sql-brand-color) 40%, black); + --sl-color-primary-950: color-mix(in srgb, var(--sql-brand-color) 28%, black); + `; +} + +export function useBrand(props: BrandProps): UseBrandResult { + const sanitizedFont = (props.brandFont || 'Nunito Sans').trim() || 'Nunito Sans'; + + useEffect(() => { + const sheet = document.createElement('link'); + sheet.setAttribute('href', buildFontsCssUrl(sanitizedFont)); + sheet.setAttribute('rel', 'stylesheet'); + document.head.appendChild(sheet); + + return () => { + if (sheet.parentNode) { + document.head.removeChild(sheet); + } + }; + }, [sanitizedFont]); + + return { + font: escapeCssString(sanitizedFont), + brandColorCss: getBrandColorCss(props.brandColor), + }; +} diff --git a/packages/lit-components/src/components/CardFeed/CardFeed.stories.ts b/packages/lit-components/src/components/CardFeed/CardFeed.stories.ts new file mode 100644 index 0000000000..2f950643d4 --- /dev/null +++ b/packages/lit-components/src/components/CardFeed/CardFeed.stories.ts @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/CardFeed', + component: 'sql-card-feed', + tags: ['autodocs'], + argTypes: { + width: { control: 'number' }, + gap: { control: 'number' }, + }, + render: (args) => html` + + + + + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WideCards: Story = { + args: { + width: 420, + }, +}; + +export const CompactGap: Story = { + args: { + gap: 12, + }, +}; diff --git a/packages/lit-components/src/components/CardFeed/CardFeed.ts b/packages/lit-components/src/components/CardFeed/CardFeed.ts new file mode 100644 index 0000000000..b404012b80 --- /dev/null +++ b/packages/lit-components/src/components/CardFeed/CardFeed.ts @@ -0,0 +1,37 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { CardFeedView } from './CardFeedView'; + +export interface CardFeedProps { + width: number; + gap: number; +} + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-card-feed': HTMLElement; + } +} + +export const CardFeed = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: CardFeedProps = { + width: parseNumber(rawProps.width, 347), + gap: parseNumber(rawProps.gap, 24), + }; + + return CardFeedView(props); + }, + 'sql-card-feed', + ['width', 'gap'] as const +); diff --git a/packages/lit-components/src/components/CardFeed/CardFeedView.ts b/packages/lit-components/src/components/CardFeed/CardFeedView.ts new file mode 100644 index 0000000000..9239a3d1f3 --- /dev/null +++ b/packages/lit-components/src/components/CardFeed/CardFeedView.ts @@ -0,0 +1,28 @@ +import { html } from 'lit'; +import type { CardFeedProps } from './CardFeed'; + +export function CardFeedView(props: CardFeedProps) { + return html` + +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/CardFeed/index.ts b/packages/lit-components/src/components/CardFeed/index.ts new file mode 100644 index 0000000000..11b2424ba3 --- /dev/null +++ b/packages/lit-components/src/components/CardFeed/index.ts @@ -0,0 +1 @@ +export * from './CardFeed'; diff --git a/packages/lit-components/src/components/CheckboxField/CheckboxField.stories.ts b/packages/lit-components/src/components/CheckboxField/CheckboxField.stories.ts new file mode 100644 index 0000000000..351116ae69 --- /dev/null +++ b/packages/lit-components/src/components/CheckboxField/CheckboxField.stories.ts @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/CheckboxField', + component: 'sql-checkbox-field', + tags: ['autodocs'], + argTypes: { + 'field-label': { control: 'text' }, + 'field-name': { control: 'text' }, + 'field-required': { control: 'boolean' }, + 'field-disabled': { control: 'boolean' }, + 'field-checked': { control: 'boolean' }, + 'field-help-text': { control: 'text' }, + 'field-size': { control: 'select', options: ['small', 'medium', 'large'] }, + }, + render: (args) => html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + 'field-name': 'terms', + 'field-label': 'I agree to the terms and conditions', + }, +}; + +export const Checked: Story = { + args: { + 'field-name': 'updates', + 'field-label': 'Send me product updates', + 'field-checked': true, + }, +}; + +export const Required: Story = { + args: { + 'field-name': 'privacy', + 'field-label': 'I accept the privacy policy', + 'field-required': true, + }, +}; + +export const Disabled: Story = { + args: { + 'field-name': 'disabledConsent', + 'field-label': 'This option is unavailable', + 'field-disabled': true, + }, +}; + +export const WithHelpText: Story = { + args: { + 'field-name': 'marketing', + 'field-label': 'Subscribe to marketing emails', + 'field-help-text': 'You can unsubscribe at any time.', + }, +}; + +export const WithError: Story = { + args: { + 'field-name': 'requiredConsent', + 'field-label': 'I confirm this action', + 'field-required': true, + }, +}; diff --git a/packages/lit-components/src/components/CheckboxField/CheckboxField.ts b/packages/lit-components/src/components/CheckboxField/CheckboxField.ts new file mode 100644 index 0000000000..69b76260d6 --- /dev/null +++ b/packages/lit-components/src/components/CheckboxField/CheckboxField.ts @@ -0,0 +1,59 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { CheckboxFieldView } from './CheckboxFieldView'; +import { useCheckboxField, useDemoCheckboxField } from './useCheckboxField'; + +export interface CheckboxFieldProps { + fieldLabel: string; + fieldName: string; + fieldRequired: boolean; + fieldDisabled: boolean; + fieldChecked: boolean; + fieldHelpText?: string; + fieldSize: 'small' | 'medium' | 'large'; +} + +function getBooleanAttribute(host: HTMLElement, name: string, defaultValue = false) { + if (!host.hasAttribute(name)) { + return defaultValue; + } + + const value = host.getAttribute(name); + return value === '' || value === 'true'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-checkbox-field': HTMLElement; + } +} + +export const CheckboxField = useComponent( + (host) => { + const rawProps = getProps(host); + const props: CheckboxFieldProps = { + fieldLabel: rawProps.fieldLabel || 'Checkbox', + fieldName: rawProps.fieldName || '', + fieldRequired: getBooleanAttribute(host, 'field-required', false), + fieldDisabled: getBooleanAttribute(host, 'field-disabled', false), + fieldChecked: getBooleanAttribute(host, 'field-checked', false), + fieldHelpText: rawProps.fieldHelpText || undefined, + fieldSize: (rawProps.fieldSize as CheckboxFieldProps['fieldSize']) || 'medium', + }; + + const hookProps = isDemo() ? useDemoCheckboxField(props) : useCheckboxField(props); + + return CheckboxFieldView({ ...props, ...hookProps }); + }, + 'sql-checkbox-field', + [ + 'field-label', + 'field-name', + 'field-required', + 'field-disabled', + 'field-checked', + 'field-help-text', + 'field-size', + ] as const +); diff --git a/packages/lit-components/src/components/CheckboxField/CheckboxFieldView.ts b/packages/lit-components/src/components/CheckboxField/CheckboxFieldView.ts new file mode 100644 index 0000000000..9b10710b75 --- /dev/null +++ b/packages/lit-components/src/components/CheckboxField/CheckboxFieldView.ts @@ -0,0 +1,53 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { CheckboxFieldProps } from './CheckboxField'; +import { useCheckboxField } from './useCheckboxField'; + +const styles = ` + :host { + display: block; + } + + .checkbox-container { + display: flex; + flex-direction: column; + gap: var(--sl-spacing-2x-small); + } + + .error-text, + .help-text { + margin: 0; + font-size: var(--sl-font-size-small); + } + + .error-text { + color: var(--sl-color-danger-600); + } + + .help-text { + color: var(--sl-color-neutral-600); + } +`; + +export function CheckboxFieldView( + props: CheckboxFieldProps & ReturnType +) { + return html` + +
+ ${UI.Checkbox({ + name: props.fieldName, + required: props.fieldRequired, + disabled: props.fieldDisabled, + checked: props.checked, + size: props.fieldSize, + onChange: props.onChange, + children: props.fieldLabel, + })} + ${props.error ? html`

${props.error}

` : ''} + ${props.fieldHelpText ? html`

${props.fieldHelpText}

` : ''} +
+ `; +} diff --git a/packages/lit-components/src/components/CheckboxField/index.ts b/packages/lit-components/src/components/CheckboxField/index.ts new file mode 100644 index 0000000000..032e30e3a8 --- /dev/null +++ b/packages/lit-components/src/components/CheckboxField/index.ts @@ -0,0 +1 @@ +export * from './CheckboxField'; diff --git a/packages/lit-components/src/components/CheckboxField/useCheckboxField.test.ts b/packages/lit-components/src/components/CheckboxField/useCheckboxField.test.ts new file mode 100644 index 0000000000..e081e06f8c --- /dev/null +++ b/packages/lit-components/src/components/CheckboxField/useCheckboxField.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useCheckboxField } from './useCheckboxField'; + +describe('useCheckboxField', () => { + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + }); + + it('should return initial unchecked state', () => { + const result = useCheckboxField({ fieldChecked: false, fieldRequired: false } as any); + + expect(result.checked).toBe(false); + expect(result.error).toBe(''); + }); + + it('should return initial checked state when fieldChecked is true', () => { + const result = useCheckboxField({ fieldChecked: true, fieldRequired: false } as any); + + expect(result.checked).toBe(true); + }); + + it('should have onChange handler', () => { + const result = useCheckboxField({ fieldChecked: false, fieldRequired: true } as any); + + result.onChange({ target: { checked: false } } as unknown as Event); + + expect(typeof result.onChange).toBe('function'); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(false); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith('This field is required'); + }); +}); diff --git a/packages/lit-components/src/components/CheckboxField/useCheckboxField.ts b/packages/lit-components/src/components/CheckboxField/useCheckboxField.ts new file mode 100644 index 0000000000..465088bd66 --- /dev/null +++ b/packages/lit-components/src/components/CheckboxField/useCheckboxField.ts @@ -0,0 +1,36 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { CheckboxFieldProps } from './CheckboxField'; + +export function useCheckboxField(props: CheckboxFieldProps) { + const [checked, setChecked] = useState(props.fieldChecked || false); + const [error, setError] = useState(''); + + function onChange(e: Event) { + const target = e.target as HTMLInputElement; + setChecked(target.checked); + if (props.fieldRequired && !target.checked) { + setError('This field is required'); + } else { + setError(''); + } + } + + return { checked, error, onChange }; +} + +export function useDemoCheckboxField(props: CheckboxFieldProps): ReturnType { + const [checked, setChecked] = useState(props.fieldChecked); + const [error, setError] = useState(''); + + function onChange(e: Event) { + const target = e.target as HTMLInputElement; + setChecked(target.checked); + if (props.fieldRequired && !target.checked) { + setError('This field is required'); + } else { + setError(''); + } + } + + return { checked, error, onChange }; +} diff --git a/packages/lit-components/src/components/CloseButton/CloseButton.stories.ts b/packages/lit-components/src/components/CloseButton/CloseButton.stories.ts new file mode 100644 index 0000000000..693aa5113f --- /dev/null +++ b/packages/lit-components/src/components/CloseButton/CloseButton.stories.ts @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/CloseButton', + component: 'sql-close-button', + tags: ['autodocs'], + argTypes: { + size: { control: 'select', options: ['small', 'medium', 'large'] }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + size: 'medium', + }, +}; + +export const Small: Story = { + args: { + size: 'small', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + }, +}; diff --git a/packages/lit-components/src/components/CloseButton/CloseButton.ts b/packages/lit-components/src/components/CloseButton/CloseButton.ts new file mode 100644 index 0000000000..3ab00912d6 --- /dev/null +++ b/packages/lit-components/src/components/CloseButton/CloseButton.ts @@ -0,0 +1,26 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { CloseButtonView } from './CloseButtonView'; + +export interface CloseButtonProps { + size?: 'small' | 'medium' | 'large'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-close-button': HTMLElement; + } +} + +export const CloseButton = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: CloseButtonProps = { + size: rawProps.size === 'small' || rawProps.size === 'large' ? rawProps.size : 'medium', + }; + + return CloseButtonView(props); + }, + 'sql-close-button', + ['size'] as const +); diff --git a/packages/lit-components/src/components/CloseButton/CloseButtonView.ts b/packages/lit-components/src/components/CloseButton/CloseButtonView.ts new file mode 100644 index 0000000000..47bb0f06a2 --- /dev/null +++ b/packages/lit-components/src/components/CloseButton/CloseButtonView.ts @@ -0,0 +1,18 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { CloseButtonProps } from './CloseButton'; + +export function CloseButtonView(props: CloseButtonProps) { + return html` + + ${UI.IconButton({ + name: 'x-lg', + label: 'Close', + style: `font-size: ${props.size === 'large' ? '1.5rem' : props.size === 'small' ? '0.75rem' : '1rem'};`, + })} + `; +} diff --git a/packages/lit-components/src/components/CloseButton/index.ts b/packages/lit-components/src/components/CloseButton/index.ts new file mode 100644 index 0000000000..8f0f3a0232 --- /dev/null +++ b/packages/lit-components/src/components/CloseButton/index.ts @@ -0,0 +1 @@ +export * from './CloseButton'; diff --git a/packages/lit-components/src/components/CodeVerification/CodeVerification.stories.ts b/packages/lit-components/src/components/CodeVerification/CodeVerification.stories.ts new file mode 100644 index 0000000000..c0ae20a5ac --- /dev/null +++ b/packages/lit-components/src/components/CodeVerification/CodeVerification.stories.ts @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { CodeVerificationView } from './CodeVerificationView'; + +const meta: Meta = { + title: 'Components/CodeVerification', + component: 'sql-code-verification', + tags: ['autodocs'], + argTypes: { + 'header-text': { control: 'text' }, + 'description-text': { control: 'text' }, + 'code-length': { control: 'number' }, + 'submit-label': { control: 'text' }, + 'resend-label': { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Enter Verification Code', + descriptionText: 'Enter the code we sent to your email.', + codeLength: 6, + submitLabel: 'Verify', + resendLabel: 'Resend Code', +}; + +export const Default: Story = {}; + +export const WithError: Story = { + render: () => + CodeVerificationView({ + ...baseProps, + code: '123', + error: 'Code must be 6 digits', + loading: false, + resent: false, + setCode: () => {}, + onSubmit: (e: Event) => e.preventDefault(), + onResend: () => {}, + }), +}; + +export const Loading: Story = { + render: () => + CodeVerificationView({ + ...baseProps, + code: '123456', + error: '', + loading: true, + resent: false, + setCode: () => {}, + onSubmit: (e: Event) => e.preventDefault(), + onResend: () => {}, + }), +}; + +export const Resent: Story = { + render: () => + CodeVerificationView({ + ...baseProps, + code: '', + error: '', + loading: false, + resent: true, + setCode: () => {}, + onSubmit: (e: Event) => e.preventDefault(), + onResend: () => {}, + }), +}; diff --git a/packages/lit-components/src/components/CodeVerification/CodeVerification.ts b/packages/lit-components/src/components/CodeVerification/CodeVerification.ts new file mode 100644 index 0000000000..047f05191f --- /dev/null +++ b/packages/lit-components/src/components/CodeVerification/CodeVerification.ts @@ -0,0 +1,37 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { CodeVerificationView } from './CodeVerificationView'; +import { useCodeVerification } from './useCodeVerification'; + +export interface CodeVerificationProps { + headerText: string; + descriptionText: string; + codeLength: number; + submitLabel: string; + resendLabel: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-code-verification': HTMLElement; + } +} + +export const CodeVerification = useComponent( + (host) => { + const rawProps = getProps(host); + const props: CodeVerificationProps = { + headerText: rawProps.headerText || 'Enter Verification Code', + descriptionText: rawProps.descriptionText || 'Enter the code we sent to your email.', + codeLength: Number(rawProps.codeLength) || 6, + submitLabel: rawProps.submitLabel || 'Verify', + resendLabel: rawProps.resendLabel || 'Resend Code', + }; + + const hookProps = useCodeVerification(props); + + return CodeVerificationView({ ...props, ...hookProps }); + }, + 'sql-code-verification', + ['header-text', 'description-text', 'code-length', 'submit-label', 'resend-label'] as const +); diff --git a/packages/lit-components/src/components/CodeVerification/CodeVerificationView.ts b/packages/lit-components/src/components/CodeVerification/CodeVerificationView.ts new file mode 100644 index 0000000000..ed3950d0ad --- /dev/null +++ b/packages/lit-components/src/components/CodeVerification/CodeVerificationView.ts @@ -0,0 +1,57 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { CodeVerificationProps } from './CodeVerification'; +import { useCodeVerification } from './useCodeVerification'; + +export function CodeVerificationView( + props: CodeVerificationProps & ReturnType +) { + return html` + +
+

${props.headerText}

+

+ ${props.descriptionText} +

+ ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} + ${props.resent ? html`${UI.Alert({ variant: 'success', open: true, children: 'Code resent!' })}` : ''} +
+ ${UI.Input({ + placeholder: 'Enter code', + value: props.code, + onInput: (e: Event) => props.setCode((e.target as HTMLInputElement).value), + maxLength: props.codeLength || 6, + })} + ${UI.Button({ + type: 'submit', + variant: 'primary', + loading: props.loading, + style: 'width: 100%; margin-top: var(--sl-spacing-small);', + children: props.submitLabel, + })} +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/CodeVerification/index.ts b/packages/lit-components/src/components/CodeVerification/index.ts new file mode 100644 index 0000000000..58deda3b44 --- /dev/null +++ b/packages/lit-components/src/components/CodeVerification/index.ts @@ -0,0 +1 @@ +export * from './CodeVerification'; diff --git a/packages/lit-components/src/components/CodeVerification/useCodeVerification.test.ts b/packages/lit-components/src/components/CodeVerification/useCodeVerification.test.ts new file mode 100644 index 0000000000..e8e2c6494e --- /dev/null +++ b/packages/lit-components/src/components/CodeVerification/useCodeVerification.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useCodeVerification } from './useCodeVerification'; + +describe('useCodeVerification', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.useFakeTimers(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = useCodeVerification({ codeLength: '6' } as any); + + expect(result.code).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + expect(result.resent).toBe(false); + }); + + it('should validate an invalid code length', () => { + universalHooksMock.values.push('123'); + const result = useCodeVerification({ codeLength: '6' } as any); + const event = createEvent(); + + result.onSubmit(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith('Code must be 6 digits'); + }); + + it('should dispatch the verify event for a valid code', () => { + universalHooksMock.values.push('123456'); + const dispatchSpy = document.dispatchEvent as ReturnType; + const result = useCodeVerification({ codeLength: '6' } as any); + + result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith(''); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:code-verify'); + expect(event.detail).toEqual({ code: '123456' }); + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(2, false); + }); + + it('should dispatch the resend event and reset resent state after a timeout', () => { + const dispatchSpy = document.dispatchEvent as ReturnType; + const result = useCodeVerification({ codeLength: '6' } as any); + + result.onResend(); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:code-resend'); + expect(universalHooksMock.setters[3]).toHaveBeenNthCalledWith(1, true); + + vi.advanceTimersByTime(3000); + + expect(universalHooksMock.setters[3]).toHaveBeenNthCalledWith(2, false); + }); +}); diff --git a/packages/lit-components/src/components/CodeVerification/useCodeVerification.ts b/packages/lit-components/src/components/CodeVerification/useCodeVerification.ts new file mode 100644 index 0000000000..d6446aa9fa --- /dev/null +++ b/packages/lit-components/src/components/CodeVerification/useCodeVerification.ts @@ -0,0 +1,36 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { CodeVerificationProps } from './CodeVerification'; + +export function useCodeVerification(props: CodeVerificationProps) { + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [resent, setResent] = useState(false); + + function onSubmit(e: Event) { + e.preventDefault(); + const codeLength = Number(props.codeLength) || 6; + if (code.length !== codeLength) { + setError(`Code must be ${codeLength} digits`); + return; + } + setLoading(true); + setError(''); + const event = new CustomEvent('sq:code-verify', { + bubbles: true, + composed: true, + detail: { code }, + }); + document.dispatchEvent(event); + setLoading(false); + } + + function onResend() { + const event = new CustomEvent('sq:code-resend', { bubbles: true, composed: true }); + document.dispatchEvent(event); + setResent(true); + setTimeout(() => setResent(false), 3000); + } + + return { code, setCode, error, loading, resent, onSubmit, onResend }; +} diff --git a/packages/lit-components/src/components/ContextRouter/ContextRouter.stories.ts b/packages/lit-components/src/components/ContextRouter/ContextRouter.stories.ts new file mode 100644 index 0000000000..46ec3daa6f --- /dev/null +++ b/packages/lit-components/src/components/ContextRouter/ContextRouter.stories.ts @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/ContextRouter', + component: 'sql-context-router', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const EmbedContext: Story = { + render: () => { + // @ts-expect-error story globals + window.widgetIdent = { engagementMedium: 'EMBED' }; + return html` + +

Embed content

+

Shared fallback content

+
+ `; + }, +}; + +export const PopupContext: Story = { + render: () => { + // @ts-expect-error story globals + window.widgetIdent = { engagementMedium: 'POPUP' }; + return html` + +

Popup content

+

Shared fallback content

+
+ `; + }, +}; diff --git a/packages/lit-components/src/components/ContextRouter/ContextRouter.ts b/packages/lit-components/src/components/ContextRouter/ContextRouter.ts new file mode 100644 index 0000000000..d2dff18563 --- /dev/null +++ b/packages/lit-components/src/components/ContextRouter/ContextRouter.ts @@ -0,0 +1,23 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { useComponent } from '../../hooks/useComponent'; +import { ContextRouterView } from './ContextRouterView'; +import { useDemoContextRouter, useContextRouter } from './useContextRouter'; + +export interface ContextRouterProps {} + +declare global { + interface HTMLElementTagNameMap { + 'sql-context-router': HTMLElement; + } +} + +export const ContextRouter = useComponent( + () => { + const props: ContextRouterProps = {}; + const hookProps = isDemo() ? useDemoContextRouter() : useContextRouter(); + + return ContextRouterView({ ...props, ...hookProps }); + }, + 'sql-context-router', + [] as const +); diff --git a/packages/lit-components/src/components/ContextRouter/ContextRouterView.ts b/packages/lit-components/src/components/ContextRouter/ContextRouterView.ts new file mode 100644 index 0000000000..a15bc836e7 --- /dev/null +++ b/packages/lit-components/src/components/ContextRouter/ContextRouterView.ts @@ -0,0 +1,17 @@ +import { html } from 'lit'; +import type { ContextRouterProps } from './ContextRouter'; +import { useContextRouter } from './useContextRouter'; + +export function ContextRouterView( + props: ContextRouterProps & ReturnType +) { + return html` + + + + `; +} diff --git a/packages/lit-components/src/components/ContextRouter/index.ts b/packages/lit-components/src/components/ContextRouter/index.ts new file mode 100644 index 0000000000..c5f87dcb9a --- /dev/null +++ b/packages/lit-components/src/components/ContextRouter/index.ts @@ -0,0 +1 @@ +export * from './ContextRouter'; diff --git a/packages/lit-components/src/components/ContextRouter/useContextRouter.test.ts b/packages/lit-components/src/components/ContextRouter/useContextRouter.test.ts new file mode 100644 index 0000000000..5609fe20dc --- /dev/null +++ b/packages/lit-components/src/components/ContextRouter/useContextRouter.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useEngagementMedium: vi.fn(() => 'EMBED'), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { useContextRouter } from './useContextRouter'; + +describe('useContextRouter', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + componentBoilerplateMock.useEngagementMedium.mockReturnValue('POPUP'); + }); + + it('should return the current engagement medium', () => { + const result = useContextRouter(); + + expect(result.engagementMedium).toBe('POPUP'); + }); + + it('should default to EMBED when no engagement medium is available', () => { + componentBoilerplateMock.useEngagementMedium.mockReturnValue(undefined); + + const result = useContextRouter(); + + expect(result.engagementMedium).toBe('EMBED'); + }); +}); diff --git a/packages/lit-components/src/components/ContextRouter/useContextRouter.ts b/packages/lit-components/src/components/ContextRouter/useContextRouter.ts new file mode 100644 index 0000000000..473b7f99d5 --- /dev/null +++ b/packages/lit-components/src/components/ContextRouter/useContextRouter.ts @@ -0,0 +1,10 @@ +import { useEngagementMedium } from '@saasquatch/component-boilerplate'; + +export function useContextRouter() { + const engagementMedium = useEngagementMedium(); + return { engagementMedium: engagementMedium || 'EMBED' }; +} + +export function useDemoContextRouter(): ReturnType { + return { engagementMedium: 'EMBED' }; +} diff --git a/packages/lit-components/src/components/CounterComponent/CounterComponent.stories.ts b/packages/lit-components/src/components/CounterComponent/CounterComponent.stories.ts new file mode 100644 index 0000000000..acee3f7483 --- /dev/null +++ b/packages/lit-components/src/components/CounterComponent/CounterComponent.stories.ts @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/CounterComponent', + component: 'lit-counter', + tags: ['autodocs'], + argTypes: { + title: { + control: 'text', + description: 'The title displayed above the counter', + defaultValue: 'Counter', + }, + 'initial-count': { + control: 'number', + description: 'The initial count value', + defaultValue: 0, + }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +/** + * Default counter with no custom attributes + */ +export const Default: Story = { + args: {}, +}; + +/** + * Counter with custom title + */ +export const CustomTitle: Story = { + args: { + title: 'My Custom Counter', + }, +}; + +/** + * Counter starting at 10 + */ +export const StartingAt10: Story = { + args: { + title: 'Counter', + 'initial-count': 10, + }, +}; + +/** + * Counter starting at 100 + */ +export const StartingAt100: Story = { + args: { + title: 'High Value Counter', + 'initial-count': 100, + }, +}; + +/** + * Counter with negative starting value + */ +export const NegativeStart: Story = { + args: { + title: 'Negative Counter', + 'initial-count': -5, + }, +}; + +/** + * Multiple counters side by side + */ +export const MultipleCounters: Story = { + render: () => html` +
+ + + +
+ `, +}; diff --git a/packages/lit-components/src/components/CounterComponent/CounterComponent.ts b/packages/lit-components/src/components/CounterComponent/CounterComponent.ts new file mode 100644 index 0000000000..f65dec366b --- /dev/null +++ b/packages/lit-components/src/components/CounterComponent/CounterComponent.ts @@ -0,0 +1,78 @@ +import { useUserIdentity, useProgramId, useQuery } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { css, html } from 'lit'; +import { gql } from 'graphql-request'; +import { useComponent } from '../../hooks/useComponent'; + +const MessageLinkQuery = gql` + query getReferralCode($programId: ID) { + user: viewer { + ... on User { + referralCode(programId: $programId) + } + } + } +`; + +/** + * A simple counter component demonstrating Haunted functional component with state management + */ +const styles = css` + :host { + display: block; + padding: 16px; + font-family: sans-serif; + } + + .counter { + font-size: 24px; + margin: 16px 0; + font-weight: bold; + } + + button { + padding: 8px 16px; + margin: 4px; + cursor: pointer; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f0f0f0; + transition: background-color 0.2s; + } + + button:hover { + background-color: #e0e0e0; + } +`; + +declare global { + interface HTMLElementTagNameMap { + 'lit-counter': HTMLElement; + } +} + +export const CounterComponent = useComponent((host: HTMLElement) => { + const title = host.getAttribute('title') || 'Counter'; + const initialCount = parseInt(host.getAttribute('initial-count') || '0', 10); + + const [counter, setCounter] = useState(initialCount); + + const increment = () => setCounter(counter + 1); + const decrement = () => setCounter(counter - 1); + const reset = () => setCounter(initialCount); + + const programId = useProgramId(); + const user = useUserIdentity(); + + const { data } = useQuery(MessageLinkQuery, { programId }, !user?.jwt); + return html` + +

${title}

+
Count: ${counter}
+ + + + `; +}, 'lit-counter'); diff --git a/packages/lit-components/src/components/CounterComponent/index.ts b/packages/lit-components/src/components/CounterComponent/index.ts new file mode 100644 index 0000000000..b92898ebb2 --- /dev/null +++ b/packages/lit-components/src/components/CounterComponent/index.ts @@ -0,0 +1,2 @@ +// CounterComponent main export +export * from './CounterComponent'; diff --git a/packages/lit-components/src/components/CouponCode/CouponCode.stories.ts b/packages/lit-components/src/components/CouponCode/CouponCode.stories.ts new file mode 100644 index 0000000000..ccde2d6bd9 --- /dev/null +++ b/packages/lit-components/src/components/CouponCode/CouponCode.stories.ts @@ -0,0 +1,176 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { CouponCodeView } from './CouponCodeView'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = '******'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: '******', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const baseProps = { + tooltipText: 'Copied to Clipboard', + tooltipLifespan: 1000, + textAlign: 'left' as const, + copyButtonLabel: 'Copy Code', + buttonStyle: 'icon' as const, + buttonType: 'primary' as const, + errorTextGeneric: 'An error occurred. Please try again.', + errorTextPending: 'Your reward is pending.', + errorTextFulfilled: 'This reward has already been fulfilled.', + errorTextCancelled: 'This reward has been cancelled.', + errorTextExpired: 'This code has expired.', + errorTextRedeemed: 'This code has been redeemed.', +}; + +const meta: Meta = { + title: 'Components/CouponCode', + component: 'sql-coupon-code', + tags: ['autodocs'], + argTypes: { + 'tooltip-text': { control: 'text' }, + 'tooltip-lifespan': { control: 'number' }, + 'text-align': { control: 'select', options: ['left', 'center', 'right'] }, + 'copy-button-label': { control: 'text' }, + 'button-style': { control: 'select', options: ['icon', 'button-outside', 'button-below'] }, + 'button-type': { control: 'select', options: ['primary', 'secondary'] }, + 'background-color': { control: 'color' }, + 'border-color': { control: 'color' }, + 'text-color': { control: 'color' }, + 'border-radius': { control: 'number' }, + }, + render: (args) => { + setupGlobals(); + return html``; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + render: () => + CouponCodeView({ + ...baseProps, + onClick: () => undefined, + open: false, + disabled: true, + loading: true, + copyString: '...', + error: '', + isCopied: false, + }), +}; + +export const ErrorGeneric: Story = { + render: () => + CouponCodeView({ + ...baseProps, + onClick: () => undefined, + open: false, + disabled: true, + loading: false, + copyString: '...', + error: baseProps.errorTextGeneric, + isCopied: false, + }), +}; + +export const ErrorPending: Story = { + render: () => + CouponCodeView({ + ...baseProps, + onClick: () => undefined, + open: false, + disabled: true, + loading: false, + copyString: '...', + error: baseProps.errorTextPending, + isCopied: false, + }), +}; + +export const ErrorExpired: Story = { + render: () => + CouponCodeView({ + ...baseProps, + onClick: () => undefined, + open: false, + disabled: true, + loading: false, + copyString: '...', + error: baseProps.errorTextExpired, + isCopied: false, + }), +}; + +export const ErrorRedeemed: Story = { + render: () => + CouponCodeView({ + ...baseProps, + onClick: () => undefined, + open: false, + disabled: true, + loading: false, + copyString: '...', + error: baseProps.errorTextRedeemed, + isCopied: false, + }), +}; + +export const ErrorCancelled: Story = { + render: () => + CouponCodeView({ + ...baseProps, + onClick: () => undefined, + open: false, + disabled: true, + loading: false, + copyString: '...', + error: baseProps.errorTextCancelled, + isCopied: false, + }), +}; + +export const CustomColors: Story = { + args: { + 'background-color': '#fff7ed', + 'border-color': '#ea580c', + 'text-color': '#9a3412', + }, +}; + +export const ButtonOutside: Story = { + args: { + 'button-style': 'button-outside', + }, +}; diff --git a/packages/lit-components/src/components/CouponCode/CouponCode.ts b/packages/lit-components/src/components/CouponCode/CouponCode.ts new file mode 100644 index 0000000000..54dc1282e1 --- /dev/null +++ b/packages/lit-components/src/components/CouponCode/CouponCode.ts @@ -0,0 +1,81 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { CouponCodeView } from './CouponCodeView'; +import { useCouponCode, useDemoCouponCode } from './useCouponCode'; + +export interface CouponCodeProps { + tooltipText: string; + tooltipLifespan: number; + textAlign: 'left' | 'center' | 'right'; + copyButtonLabel: string; + buttonStyle: 'icon' | 'button-outside' | 'button-below'; + buttonType: 'primary' | 'secondary'; + backgroundColor?: string; + borderColor?: string; + textColor?: string; + borderRadius?: number; + programId?: string; + rewardKey?: string; + tooltipCopyText?: string; + tooltipCopiedText?: string; + errorTextGeneric: string; + errorTextPending: string; + errorTextFulfilled: string; + errorTextCancelled: string; + errorTextExpired: string; + errorTextRedeemed: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-coupon-code': HTMLElement; + } +} + +export const CouponCode = useComponent( + (host) => { + const props: CouponCodeProps = { + tooltipText: 'Copied to Clipboard', + tooltipLifespan: 1000, + textAlign: 'left', + copyButtonLabel: 'Copy Code', + buttonStyle: 'icon', + buttonType: 'primary', + errorTextGeneric: 'An error occurred. Please try again.', + errorTextPending: 'Your reward is pending.', + errorTextFulfilled: 'This reward has already been fulfilled.', + errorTextCancelled: 'This reward has been cancelled.', + errorTextExpired: 'This code has expired.', + errorTextRedeemed: 'This code has been redeemed.', + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoCouponCode(props) : useCouponCode(props); + + return CouponCodeView({ ...props, ...hookProps }); + }, + 'sql-coupon-code', + [ + 'tooltip-text', + 'tooltip-lifespan', + 'text-align', + 'copy-button-label', + 'button-style', + 'button-type', + 'background-color', + 'border-color', + 'text-color', + 'border-radius', + 'program-id', + 'reward-key', + 'tooltip-copy-text', + 'tooltip-copied-text', + 'error-text-generic', + 'error-text-pending', + 'error-text-fulfilled', + 'error-text-cancelled', + 'error-text-expired', + 'error-text-redeemed', + ] as const +); diff --git a/packages/lit-components/src/components/CouponCode/CouponCodeView.ts b/packages/lit-components/src/components/CouponCode/CouponCodeView.ts new file mode 100644 index 0000000000..802abd35d6 --- /dev/null +++ b/packages/lit-components/src/components/CouponCode/CouponCodeView.ts @@ -0,0 +1,139 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { ButtonVariant } from '../../ui/types'; +import { CouponCodeProps } from './CouponCode'; +import { useCouponCode } from './useCouponCode'; + +const baseStyles = ` + :host { + display: block; + } + + sl-input { + width: 100%; + } + + .container-div { + display: flex; + align-items: flex-start; + flex-direction: column; + justify-content: center; + gap: var(--sl-spacing-x-small); + width: 100%; + } + + .container-style { + display: flex; + align-items: center; + gap: var(--sl-spacing-x-small); + width: 100%; + } + + .error-text { + margin: 0; + color: var(--sqm-danger-color-text); + } +`; + +const getDynamicStyles = (props: CouponCodeProps) => ` + .container-div { + text-align: ${props.textAlign || 'left'}; + } + + sl-input::part(input) { + text-overflow: ellipsis; + width: 100%; + text-align: ${props.textAlign || 'left'}; + color: ${props.textColor || 'var(--sl-input-color)'}; + } + + sl-input::part(base) { + --sl-input-border-radius: ${props.borderRadius ? `${props.borderRadius}px` : 'var(--sqm-border-radius-normal)'}; + --sl-input-border-color: var(--sqm-border-color); + --sl-input-border-color-hover: #999999; + --sl-input-border-color-focus: #999999; + --sl-input-color-hover: var(--sqm-input-color-hover); + --sl-input-color-focus: var(--sqm-input-color-focus); + --sl-input-color-disabled: var(--sqm-input-disabled-color); + --sl-input-background-color-focus: var(--sqm-input-background); + --sl-input-background-color-hover: var(--sqm-input-background); + cursor: pointer; + overflow: visible; + border-radius: ${props.borderRadius ? `${props.borderRadius}px` : 'var(--sqm-border-radius-normal)'}; + background: ${props.backgroundColor || 'var(--sqm-input-background)'}; + border: var(--sqm-border-thickness) solid ${props.borderColor || 'var(--sqm-input-border-color)'}; + } + + sl-icon::part(base) { + color: ${props.textColor || 'var(--sqm-text)'}; + } + + sl-icon-button::part(base) { + color: ${props.textColor || 'var(--sqm-text)'}; + } +`; + +export function CouponCodeView(props: CouponCodeProps & ReturnType) { + const dynamicStyles = getDynamicStyles(props); + const buttonStyle = props.buttonStyle || 'icon'; + const disabled = props.loading || props.disabled || !!props.error; + const tooltipPlacement = + props.buttonStyle === 'button-below' + ? 'bottom' + : props.buttonStyle === 'button-outside' + ? 'top' + : 'top-end'; + const tooltipContent = props.open + ? props.tooltipCopiedText || props.tooltipText + : props.tooltipCopyText || props.copyButtonLabel; + + const copyButton = html` + ${UI.Tooltip({ + trigger: 'manual', + content: tooltipContent, + placement: tooltipPlacement, + disabled: disabled, + open: props.open, + skidding: props.buttonStyle === 'icon' ? -5 : 0, + slot: 'suffix', + children: buttonStyle === 'icon' + ? UI.IconButton({ + exportparts: 'base: icon-button-base', + onClick: () => props.onClick?.(), + name: 'files', + disabled: disabled, + }) + : UI.Button({ + exportparts: `base: ${props.buttonType || 'primary'}button-base`, + onClick: () => props.onClick?.(), + size: 'medium', + style: buttonStyle === 'button-below' ? 'width: 100%' : '', + disabled: disabled, + variant: (props.buttonType || 'primary') as ButtonVariant, + children: props.copyButtonLabel || 'Copy Code', + }), + })} + `; + + return html` + +
+
+ ${UI.Input({ + value: props.loading ? 'Loading...' : props.copyString, + readonly: true, + disabled: disabled, + suffix: buttonStyle === 'icon' ? copyButton : undefined, + error: props.error ? html`

${props.error}

` : undefined, + })} + ${buttonStyle === 'button-outside' || buttonStyle === 'button-below' ? copyButton : ''} +
+
+ `; +} diff --git a/packages/lit-components/src/components/CouponCode/index.ts b/packages/lit-components/src/components/CouponCode/index.ts new file mode 100644 index 0000000000..0007447430 --- /dev/null +++ b/packages/lit-components/src/components/CouponCode/index.ts @@ -0,0 +1 @@ +export * from './CouponCode'; diff --git a/packages/lit-components/src/components/CouponCode/useCouponCode.test.ts b/packages/lit-components/src/components/CouponCode/useCouponCode.test.ts new file mode 100644 index 0000000000..778c2f3eaf --- /dev/null +++ b/packages/lit-components/src/components/CouponCode/useCouponCode.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { user: { rewards: { data: [{ fuelTankCode: 'CODE123', statuses: [] }] } } }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useCouponCode } from './useCouponCode'; + +describe('useCouponCode', () => { + const props = { + programId: 'fallback-program', + rewardKey: 'reward-key', + tooltipLifespan: 2000, + errorTextPending: 'Pending', + errorTextCancelled: 'Cancelled', + errorTextExpired: 'Expired', + errorTextRedeemed: 'Redeemed', + errorTextFulfilled: 'Fulfilled', + errorTextGeneric: 'Generic', + } as const; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.useFakeTimers(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + componentBoilerplateMock.useProgramId.mockReturnValue('test-program'); + componentBoilerplateMock.useUserIdentity.mockReturnValue({ jwt: 'test-jwt' }); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { rewards: { data: [{ fuelTankCode: 'CODE123', statuses: [] }] } } }, + loading: false, + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn() }, + }); + }); + + it('should return coupon data from the query', () => { + const result = useCouponCode(props as any); + + expect(result.copyString).toBe('CODE123'); + expect(result.loading).toBe(false); + expect(result.error).toBe(''); + expect(result.disabled).toBe(false); + }); + + it.each([ + ['EXPIRED', 'Expired'], + ['CANCELLED', 'Cancelled'], + ['REDEEMED', 'Redeemed'], + ])('should return the configured error for %s rewards', (status, message) => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { rewards: { data: [{ fuelTankCode: '', statuses: [status] }] } } }, + loading: false, + }); + + const result = useCouponCode(props as any); + + expect(result.error).toBe(message); + expect(result.disabled).toBe(true); + }); + + it('should copy the coupon code when clicked', () => { + const result = useCouponCode(props as any); + + result.onClick(); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('CODE123'); + expect(universalHooksMock.setters[0]).toHaveBeenNthCalledWith(1, true); + vi.advanceTimersByTime(2000); + expect(universalHooksMock.setters[0]).toHaveBeenNthCalledWith(2, false); + }); +}); diff --git a/packages/lit-components/src/components/CouponCode/useCouponCode.ts b/packages/lit-components/src/components/CouponCode/useCouponCode.ts new file mode 100644 index 0000000000..888a0705c8 --- /dev/null +++ b/packages/lit-components/src/components/CouponCode/useCouponCode.ts @@ -0,0 +1,88 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { CouponCodeProps } from './CouponCode'; + +const CouponCodeQuery = gql` + query getCouponCode($programId: ID, $rewardKey: String) { + user: viewer { + ... on User { + rewards( + filter: { programId_eq: $programId, type_eq: "FUEL_TANK", fuelTankCode_exists: true } + ) { + data { + fuelTankCode + dateExpires + statuses + } + } + } + } + } +`; + +export function useCouponCode(props: CouponCodeProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const { data, loading } = useQuery( + CouponCodeQuery, + { programId, rewardKey: props.rewardKey }, + !user?.jwt + ); + const [open, setOpen] = useState(false); + + const rewards = data?.user?.rewards?.data || []; + const reward = rewards[0]; + const code = reward?.fuelTankCode; + const statuses = reward?.statuses || []; + + let error = ''; + if (!loading && !code) { + if (statuses.includes('PENDING')) error = props.errorTextPending; + else if (statuses.includes('CANCELLED')) error = props.errorTextCancelled; + else if (statuses.includes('EXPIRED')) error = props.errorTextExpired; + else if (statuses.includes('REDEEMED')) error = props.errorTextRedeemed; + else if (statuses.includes('FULFILLED')) error = props.errorTextFulfilled; + else error = props.errorTextGeneric; + } + + const copyString = code || '...'; + + function onClick() { + if (!code) return; + navigator.clipboard.writeText(code); + setOpen(true); + setTimeout(() => setOpen(false), props.tooltipLifespan); + } + + return { + onClick, + open, + disabled: loading || !!error, + loading, + copyString, + error, + isCopied: false, + }; +} + +export function useDemoCouponCode(props: CouponCodeProps) { + const [open, setOpen] = useState(false); + const copyString = 'THANKSJANE125uv125'; + + function onClick() { + navigator.clipboard.writeText(copyString); + setOpen(true); + setTimeout(() => setOpen(false), props.tooltipLifespan); + } + + return { + onClick, + open, + disabled: false, + loading: false, + copyString, + error: '', + isCopied: false, + }; +} diff --git a/packages/lit-components/src/components/DividedLayout/DividedLayout.stories.ts b/packages/lit-components/src/components/DividedLayout/DividedLayout.stories.ts new file mode 100644 index 0000000000..0e85060a3c --- /dev/null +++ b/packages/lit-components/src/components/DividedLayout/DividedLayout.stories.ts @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/DividedLayout', + component: 'sql-divided-layout', + tags: ['autodocs'], + argTypes: { + direction: { control: 'select', options: ['row', 'column'] }, + gap: { control: 'text' }, + 'divider-color': { control: 'color' }, + 'divider-width': { control: 'text' }, + }, + render: (args) => html` + +
First section
+
Second section
+
Third section
+
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Horizontal: Story = { + args: { + direction: 'row', + }, +}; + +export const Vertical: Story = { + args: { + direction: 'column', + }, +}; + +export const CustomGap: Story = { + args: { + gap: '2rem', + }, +}; + +export const CustomDivider: Story = { + args: { + 'divider-color': '#6366f1', + 'divider-width': '2px', + }, +}; diff --git a/packages/lit-components/src/components/DividedLayout/DividedLayout.ts b/packages/lit-components/src/components/DividedLayout/DividedLayout.ts new file mode 100644 index 0000000000..b817dc3202 --- /dev/null +++ b/packages/lit-components/src/components/DividedLayout/DividedLayout.ts @@ -0,0 +1,33 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { DividedLayoutView } from './DividedLayoutView'; + +export interface DividedLayoutProps { + direction?: 'row' | 'column'; + gap?: string; + dividerColor?: string; + dividerWidth?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-divided-layout': HTMLElement; + } +} + +export const DividedLayout = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: DividedLayoutProps = { + direction: rawProps.direction === 'column' ? 'column' : 'row', + gap: typeof rawProps.gap === 'string' ? rawProps.gap : 'var(--sl-spacing-medium)', + dividerColor: typeof rawProps.dividerColor === 'string' ? rawProps.dividerColor : undefined, + dividerWidth: + typeof rawProps.dividerWidth === 'string' ? rawProps.dividerWidth : '1px', + }; + + return DividedLayoutView(props); + }, + 'sql-divided-layout', + ['direction', 'gap', 'divider-color', 'divider-width'] as const +); diff --git a/packages/lit-components/src/components/DividedLayout/DividedLayoutView.ts b/packages/lit-components/src/components/DividedLayout/DividedLayoutView.ts new file mode 100644 index 0000000000..cb84b69332 --- /dev/null +++ b/packages/lit-components/src/components/DividedLayout/DividedLayoutView.ts @@ -0,0 +1,34 @@ +import { html } from 'lit'; +import { DividedLayoutProps } from './DividedLayout'; + +export function DividedLayoutView(props: DividedLayoutProps) { + const direction = props.direction || 'row'; + const gap = props.gap || 'var(--sl-spacing-medium)'; + const dividerColor = props.dividerColor || 'var(--sl-color-neutral-200)'; + const dividerWidth = props.dividerWidth || '1px'; + const dividerStyles = + direction === 'row' + ? `border-right: ${dividerWidth} solid ${dividerColor}; padding-right: ${gap};` + : `border-bottom: ${dividerWidth} solid ${dividerColor}; padding-bottom: ${gap};`; + + return html` + +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/DividedLayout/index.ts b/packages/lit-components/src/components/DividedLayout/index.ts new file mode 100644 index 0000000000..8ce947a5b0 --- /dev/null +++ b/packages/lit-components/src/components/DividedLayout/index.ts @@ -0,0 +1 @@ +export * from './DividedLayout'; diff --git a/packages/lit-components/src/components/DocusignForm/DocusignForm.stories.ts b/packages/lit-components/src/components/DocusignForm/DocusignForm.stories.ts new file mode 100644 index 0000000000..c9445ed3ad --- /dev/null +++ b/packages/lit-components/src/components/DocusignForm/DocusignForm.stories.ts @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { DocusignFormView } from './DocusignFormView'; + +const meta: Meta = { + title: 'Components/DocusignForm', + component: 'sql-docusign-form', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Tax Document Signing', + descriptionText: 'Please sign the tax document to continue.', + iframeUrl: 'https://example.com/sign', + iframeHeight: '600px', + programId: 'demo-program', + docusignUrl: 'https://example.com/sign', + loading: false, + signed: false, +}; + +export const Default: Story = { + render: () => DocusignFormView(baseProps), +}; + +export const Loading: Story = { + render: () => + DocusignFormView({ + ...baseProps, + loading: true, + }), +}; + +export const Signed: Story = { + render: () => + DocusignFormView({ + ...baseProps, + signed: true, + }), +}; + +export const NoUrl: Story = { + render: () => + DocusignFormView({ + ...baseProps, + iframeUrl: '', + docusignUrl: '', + }), +}; diff --git a/packages/lit-components/src/components/DocusignForm/DocusignForm.ts b/packages/lit-components/src/components/DocusignForm/DocusignForm.ts new file mode 100644 index 0000000000..b8be3ae07c --- /dev/null +++ b/packages/lit-components/src/components/DocusignForm/DocusignForm.ts @@ -0,0 +1,42 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { DocusignFormView } from './DocusignFormView'; +import { useDemoDocusignForm, useDocusignForm } from './useDocusignForm'; + +export interface DocusignFormProps { + headerText: string; + descriptionText: string; + iframeUrl?: string; + iframeHeight: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-docusign-form': HTMLElement; + } +} + +export const DocusignForm = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: DocusignFormProps = { + headerText: + typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Tax Document Signing', + descriptionText: + typeof rawProps.descriptionText === 'string' + ? rawProps.descriptionText + : 'Please sign the tax document to continue.', + iframeUrl: typeof rawProps.iframeUrl === 'string' ? rawProps.iframeUrl : undefined, + iframeHeight: typeof rawProps.iframeHeight === 'string' ? rawProps.iframeHeight : '600px', + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + }; + + const hookProps = isDemo() ? useDemoDocusignForm(props) : useDocusignForm(props); + + return DocusignFormView({ ...props, ...hookProps }); + }, + 'sql-docusign-form', + ['header-text', 'description-text', 'iframe-url', 'iframe-height', 'program-id'] as const +); diff --git a/packages/lit-components/src/components/DocusignForm/DocusignFormView.ts b/packages/lit-components/src/components/DocusignForm/DocusignFormView.ts new file mode 100644 index 0000000000..d0f25760d3 --- /dev/null +++ b/packages/lit-components/src/components/DocusignForm/DocusignFormView.ts @@ -0,0 +1,51 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { DocusignFormProps } from './DocusignForm'; +import { useDocusignForm } from './useDocusignForm'; + +export function DocusignFormView(props: DocusignFormProps & ReturnType) { + return html` + +
+ ${props.signed + ? html` + ${UI.Alert({ + variant: 'success', + open: true, + icon: UI.Icon({ name: 'check-circle' }), + children: 'Document signed successfully!', + })} + ` + : props.loading + ? html`${UI.Spinner({ style: 'font-size: 2rem;' })}` + : html` +
+

${props.headerText}

+

${props.descriptionText}

+
+ ${props.docusignUrl + ? html`` + : html`${UI.Alert({ variant: 'warning', open: true, children: 'Document signing URL not available.' })}`} + `} +
+ `; +} diff --git a/packages/lit-components/src/components/DocusignForm/index.ts b/packages/lit-components/src/components/DocusignForm/index.ts new file mode 100644 index 0000000000..397e536294 --- /dev/null +++ b/packages/lit-components/src/components/DocusignForm/index.ts @@ -0,0 +1 @@ +export * from './DocusignForm'; diff --git a/packages/lit-components/src/components/DocusignForm/useDocusignForm.test.ts b/packages/lit-components/src/components/DocusignForm/useDocusignForm.test.ts new file mode 100644 index 0000000000..7252e35353 --- /dev/null +++ b/packages/lit-components/src/components/DocusignForm/useDocusignForm.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { viewer: { taxHandling: { publisher: { docusignUrl: 'https://example.com/sign' } } } }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useEffect: vi.fn((callback: () => void) => callback()), + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useEffect: universalHooksMock.useEffect, + useState: universalHooksMock.useState, +})); + +import { useDocusignForm } from './useDocusignForm'; + +describe('useDocusignForm', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + componentBoilerplateMock.useProgramId.mockReturnValue('test-program'); + componentBoilerplateMock.useUserIdentity.mockReturnValue({ jwt: 'test-jwt' }); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { taxHandling: { publisher: { docusignUrl: 'https://example.com/sign' } } } }, + loading: false, + }); + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + }); + + it('should return the initial query state', () => { + const result = useDocusignForm({ programId: '' } as any); + + expect(result.docusignUrl).toBe('https://example.com/sign'); + expect(result.loading).toBe(false); + expect(result.signed).toBe(false); + }); + + it('should prefer the iframeUrl prop when provided', () => { + const result = useDocusignForm({ iframeUrl: 'https://iframe.example.com', programId: '' } as any); + + expect(result.docusignUrl).toBe('https://iframe.example.com'); + }); + + it('should mark the form as signed after a docusign message', () => { + useDocusignForm({ programId: '' } as any); + + const addEventListenerSpy = window.addEventListener as ReturnType; + const handler = addEventListenerSpy.mock.calls[0][1] as EventListener; + handler({ data: { type: 'docusign-complete' } } as MessageEvent); + + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(true); + }); +}); diff --git a/packages/lit-components/src/components/DocusignForm/useDocusignForm.ts b/packages/lit-components/src/components/DocusignForm/useDocusignForm.ts new file mode 100644 index 0000000000..e2a2eb1339 --- /dev/null +++ b/packages/lit-components/src/components/DocusignForm/useDocusignForm.ts @@ -0,0 +1,55 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useEffect, useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { DocusignFormProps } from './DocusignForm'; + +const DOCUSIGN_URL_QUERY = gql` + query getDocusignUrl($programId: ID) { + viewer { + ... on User { + taxHandling(programId: $programId) { + publisher { + docusignUrl + } + } + } + } + } +`; + +export function useDocusignForm(props: DocusignFormProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const { data, loading } = useQuery(DOCUSIGN_URL_QUERY, { programId }, !user?.jwt); + const [signed, setSigned] = useState(false); + + const docusignUrl = props.iframeUrl || data?.viewer?.taxHandling?.publisher?.docusignUrl || ''; + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const onMessage = (event: MessageEvent<{ type?: string }>) => { + if (event.data?.type === 'docusign-complete') { + setSigned(true); + } + }; + + window.addEventListener('message', onMessage as EventListener); + + return () => { + window.removeEventListener('message', onMessage as EventListener); + }; + }, []); + + return { docusignUrl, loading, signed }; +} + +export function useDemoDocusignForm(props: DocusignFormProps): ReturnType { + return { + docusignUrl: props.iframeUrl || 'https://example.com/demo-docusign', + loading: false, + signed: false, + }; +} diff --git a/packages/lit-components/src/components/DropdownField/DropdownField.stories.ts b/packages/lit-components/src/components/DropdownField/DropdownField.stories.ts new file mode 100644 index 0000000000..738e6f3c00 --- /dev/null +++ b/packages/lit-components/src/components/DropdownField/DropdownField.stories.ts @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const defaultOptions = 'Option 1, Option 2, Option 3'; + +const meta: Meta = { + title: 'Components/DropdownField', + component: 'sql-dropdown-field', + tags: ['autodocs'], + argTypes: { + 'field-label': { control: 'text' }, + 'field-name': { control: 'text' }, + 'field-required': { control: 'boolean' }, + 'field-disabled': { control: 'boolean' }, + 'field-placeholder': { control: 'text' }, + 'field-help-text': { control: 'text' }, + 'field-size': { control: 'select', options: ['small', 'medium', 'large'] }, + 'field-options': { control: 'text' }, + }, + render: (args) => html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + 'field-name': 'favoriteColor', + }, +}; + +export const WithOptions: Story = { + args: { + 'field-name': 'country', + 'field-label': 'Country', + 'field-options': '[{"label":"Canada","value":"ca"},{"label":"United States","value":"us"},{"label":"United Kingdom","value":"uk"}]', + }, +}; + +export const Required: Story = { + args: { + 'field-name': 'department', + 'field-label': 'Department', + 'field-required': true, + }, +}; + +export const Disabled: Story = { + args: { + 'field-name': 'disabledSelect', + 'field-label': 'Disabled select', + 'field-disabled': true, + }, +}; + +export const WithHelpText: Story = { + args: { + 'field-name': 'timezone', + 'field-label': 'Timezone', + 'field-help-text': 'Select the timezone used for notifications.', + }, +}; + +export const WithPreselected: Story = { + args: { + 'field-name': 'size', + 'field-label': 'T-Shirt Size', + 'field-options': 'Small, Medium, Large', + }, +}; diff --git a/packages/lit-components/src/components/DropdownField/DropdownField.ts b/packages/lit-components/src/components/DropdownField/DropdownField.ts new file mode 100644 index 0000000000..e70279a446 --- /dev/null +++ b/packages/lit-components/src/components/DropdownField/DropdownField.ts @@ -0,0 +1,67 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { DropdownFieldView } from './DropdownFieldView'; +import { useDropdownField, useDemoDropdownField } from './useDropdownField'; + +export interface DropdownFieldOption { + label: string; + value: string; +} + +export interface DropdownFieldProps { + fieldLabel: string; + fieldName: string; + fieldRequired: boolean; + fieldDisabled: boolean; + fieldPlaceholder: string; + fieldHelpText?: string; + fieldSize: 'small' | 'medium' | 'large'; + fieldOptions?: string; +} + +function getBooleanAttribute(host: HTMLElement, name: string, defaultValue = false) { + if (!host.hasAttribute(name)) { + return defaultValue; + } + + const value = host.getAttribute(name); + return value === '' || value === 'true'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-dropdown-field': HTMLElement; + } +} + +export const DropdownField = useComponent( + (host) => { + const rawProps = getProps(host); + const props: DropdownFieldProps = { + fieldLabel: rawProps.fieldLabel || 'Select', + fieldName: rawProps.fieldName || '', + fieldRequired: getBooleanAttribute(host, 'field-required', false), + fieldDisabled: getBooleanAttribute(host, 'field-disabled', false), + fieldPlaceholder: rawProps.fieldPlaceholder || 'Select an option', + fieldHelpText: rawProps.fieldHelpText || undefined, + fieldSize: (rawProps.fieldSize as DropdownFieldProps['fieldSize']) || 'medium', + fieldOptions: rawProps.fieldOptions || undefined, + }; + + const hookProps = isDemo() ? useDemoDropdownField(props) : useDropdownField(props); + + return DropdownFieldView({ ...props, ...hookProps }); + }, + 'sql-dropdown-field', + [ + 'field-label', + 'field-name', + 'field-required', + 'field-disabled', + 'field-placeholder', + 'field-help-text', + 'field-size', + 'field-options', + ] as const +); diff --git a/packages/lit-components/src/components/DropdownField/DropdownFieldView.ts b/packages/lit-components/src/components/DropdownField/DropdownFieldView.ts new file mode 100644 index 0000000000..61487a02db --- /dev/null +++ b/packages/lit-components/src/components/DropdownField/DropdownFieldView.ts @@ -0,0 +1,39 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { DropdownFieldProps } from './DropdownField'; +import { useDropdownField } from './useDropdownField'; + +const styles = ` + :host { + display: block; + } + + sl-select { + width: 100%; + } +`; + +export function DropdownFieldView( + props: DropdownFieldProps & ReturnType +) { + return html` + + ${UI.Select({ + label: props.fieldLabel, + name: props.fieldName, + placeholder: props.fieldPlaceholder, + required: props.fieldRequired, + disabled: props.fieldDisabled, + size: props.fieldSize, + value: props.value, + onChange: props.onChange, + error: props.error, + helpText: props.fieldHelpText, + children: html`${props.options.map( + (opt) => html`${UI.Option({ value: opt.value, children: opt.label })}` + )}`, + })} + `; +} diff --git a/packages/lit-components/src/components/DropdownField/index.ts b/packages/lit-components/src/components/DropdownField/index.ts new file mode 100644 index 0000000000..79510c1a82 --- /dev/null +++ b/packages/lit-components/src/components/DropdownField/index.ts @@ -0,0 +1 @@ +export * from './DropdownField'; diff --git a/packages/lit-components/src/components/DropdownField/useDropdownField.test.ts b/packages/lit-components/src/components/DropdownField/useDropdownField.test.ts new file mode 100644 index 0000000000..c218138b44 --- /dev/null +++ b/packages/lit-components/src/components/DropdownField/useDropdownField.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useDropdownField } from './useDropdownField'; + +describe('useDropdownField', () => { + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + }); + + it('should return empty initial state', () => { + const result = useDropdownField({ fieldOptions: '', fieldRequired: false } as any); + + expect(result.value).toBe(''); + expect(result.error).toBe(''); + }); + + it('should parse JSON options', () => { + const options = JSON.stringify([ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + ]); + const result = useDropdownField({ fieldOptions: options, fieldRequired: false } as any); + + expect(result.options).toHaveLength(2); + expect(result.options[0].label).toBe('Option 1'); + }); + + it('should parse comma-separated options', () => { + const result = useDropdownField({ + fieldOptions: 'Apple, Banana, Cherry', + fieldRequired: false, + } as any); + + expect(result.options).toHaveLength(3); + expect(result.options[0].value).toBe('Apple'); + }); + + it('should have onChange handler', () => { + const result = useDropdownField({ fieldOptions: '', fieldRequired: true } as any); + + result.onChange({ target: { value: '' } } as unknown as Event); + + expect(typeof result.onChange).toBe('function'); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith('This field is required'); + }); +}); diff --git a/packages/lit-components/src/components/DropdownField/useDropdownField.ts b/packages/lit-components/src/components/DropdownField/useDropdownField.ts new file mode 100644 index 0000000000..90d30567ff --- /dev/null +++ b/packages/lit-components/src/components/DropdownField/useDropdownField.ts @@ -0,0 +1,56 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { DropdownFieldOption, DropdownFieldProps } from './DropdownField'; + +function parseOptions(fieldOptions?: string): DropdownFieldOption[] { + if (!fieldOptions) return []; + try { + return JSON.parse(fieldOptions) as DropdownFieldOption[]; + } catch { + return fieldOptions.split(',').map((opt: string) => ({ + label: opt.trim(), + value: opt.trim(), + })); + } +} + +export function useDropdownField(props: DropdownFieldProps) { + const [value, setValue] = useState(''); + const [error, setError] = useState(''); + + function onChange(e: Event) { + const target = e.target as HTMLSelectElement; + setValue(target.value); + if (props.fieldRequired && !target.value) { + setError('This field is required'); + } else { + setError(''); + } + } + + return { value, error, options: parseOptions(props.fieldOptions), onChange }; +} + +export function useDemoDropdownField(props: DropdownFieldProps): ReturnType { + const demoOptions = parseOptions(props.fieldOptions); + const options = demoOptions.length + ? demoOptions + : [ + { label: 'United States', value: 'united-states' }, + { label: 'Canada', value: 'canada' }, + { label: 'United Kingdom', value: 'united-kingdom' }, + ]; + const [value, setValue] = useState(options[0]?.value || ''); + const [error, setError] = useState(''); + + function onChange(e: Event) { + const target = e.target as HTMLSelectElement; + setValue(target.value); + if (props.fieldRequired && !target.value) { + setError('This field is required'); + } else { + setError(''); + } + } + + return { value, error, options, onChange }; +} diff --git a/packages/lit-components/src/components/EditProfile/EditProfile.stories.ts b/packages/lit-components/src/components/EditProfile/EditProfile.stories.ts new file mode 100644 index 0000000000..3dcd4c2719 --- /dev/null +++ b/packages/lit-components/src/components/EditProfile/EditProfile.stories.ts @@ -0,0 +1,105 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { EditProfileView } from './EditProfileView'; + +const meta: Meta = { + title: 'Components/EditProfile', + component: 'sql-edit-profile', + tags: ['autodocs'], + argTypes: { + 'header-text': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'first-name-label': { control: 'text' }, + 'last-name-label': { control: 'text' }, + 'success-message': { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Edit Profile', + submitLabel: 'Save Changes', + firstNameLabel: 'First Name', + lastNameLabel: 'Last Name', + successMessage: 'Profile updated successfully!', +}; + +export const Default: Story = { + render: () => + EditProfileView({ + ...baseProps, + firstName: 'Jane', + setFirstName: () => {}, + lastName: 'Doe', + setLastName: () => {}, + email: 'jane@example.com', + error: '', + success: false, + loading: false, + saving: false, + onSubmit: (e: Event) => e.preventDefault(), + }), +}; + +export const Loading: Story = { + render: () => + EditProfileView({ + ...baseProps, + firstName: '', + setFirstName: () => {}, + lastName: '', + setLastName: () => {}, + email: '', + error: '', + success: false, + loading: true, + saving: false, + onSubmit: (e: Event) => e.preventDefault(), + }), +}; + +export const WithError: Story = { + render: () => + EditProfileView({ + ...baseProps, + firstName: 'Jane', + setFirstName: () => {}, + lastName: 'Doe', + setLastName: () => {}, + email: 'jane@example.com', + error: 'Failed to update profile. Please try again.', + success: false, + loading: false, + saving: false, + onSubmit: (e: Event) => e.preventDefault(), + }), +}; + +export const Success: Story = { + render: () => + EditProfileView({ + ...baseProps, + firstName: 'Jane', + setFirstName: () => {}, + lastName: 'Doe', + setLastName: () => {}, + email: 'jane@example.com', + error: '', + success: true, + loading: false, + saving: false, + onSubmit: (e: Event) => e.preventDefault(), + }), +}; diff --git a/packages/lit-components/src/components/EditProfile/EditProfile.ts b/packages/lit-components/src/components/EditProfile/EditProfile.ts new file mode 100644 index 0000000000..c511560f98 --- /dev/null +++ b/packages/lit-components/src/components/EditProfile/EditProfile.ts @@ -0,0 +1,38 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { EditProfileView } from './EditProfileView'; +import { useDemoEditProfile, useEditProfile } from './useEditProfile'; + +export interface EditProfileProps { + headerText: string; + submitLabel: string; + firstNameLabel: string; + lastNameLabel: string; + successMessage: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-edit-profile': HTMLElement; + } +} + +export const EditProfile = useComponent( + (host) => { + const rawProps = getProps(host); + const props: EditProfileProps = { + headerText: rawProps.headerText || 'Edit Profile', + submitLabel: rawProps.submitLabel || 'Save Changes', + firstNameLabel: rawProps.firstNameLabel || 'First Name', + lastNameLabel: rawProps.lastNameLabel || 'Last Name', + successMessage: rawProps.successMessage || 'Profile updated successfully!', + }; + + const hookProps = isDemo() ? useDemoEditProfile(props) : useEditProfile(props); + + return EditProfileView({ ...props, ...hookProps }); + }, + 'sql-edit-profile', + ['header-text', 'submit-label', 'first-name-label', 'last-name-label', 'success-message'] as const +); diff --git a/packages/lit-components/src/components/EditProfile/EditProfileView.ts b/packages/lit-components/src/components/EditProfile/EditProfileView.ts new file mode 100644 index 0000000000..ae1d4fc6f5 --- /dev/null +++ b/packages/lit-components/src/components/EditProfile/EditProfileView.ts @@ -0,0 +1,63 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { EditProfileProps } from './EditProfile'; +import { useEditProfile } from './useEditProfile'; + +export function EditProfileView(props: EditProfileProps & ReturnType) { + return html` + +
+

${props.headerText}

+ ${props.success ? html`${UI.Alert({ variant: 'success', open: true, children: props.successMessage })}` : ''} + ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} + ${props.loading + ? html`${UI.Spinner({})}` + : html` +
+
+ ${UI.Input({ + label: props.firstNameLabel, + value: props.firstName, + onInput: (e: Event) => props.setFirstName((e.target as HTMLInputElement).value), + style: 'flex: 1;', + })} + ${UI.Input({ + label: props.lastNameLabel, + value: props.lastName, + onInput: (e: Event) => props.setLastName((e.target as HTMLInputElement).value), + style: 'flex: 1;', + })} +
+ ${UI.Input({ label: 'Email', value: props.email, disabled: true })} + + ${UI.Button({ + type: 'submit', + variant: 'primary', + loading: props.saving, + style: 'margin-top: var(--sl-spacing-medium);', + children: props.submitLabel, + })} +
+ `} +
+ `; +} diff --git a/packages/lit-components/src/components/EditProfile/index.ts b/packages/lit-components/src/components/EditProfile/index.ts new file mode 100644 index 0000000000..a839133bf6 --- /dev/null +++ b/packages/lit-components/src/components/EditProfile/index.ts @@ -0,0 +1 @@ +export * from './EditProfile'; diff --git a/packages/lit-components/src/components/EditProfile/useEditProfile.test.ts b/packages/lit-components/src/components/EditProfile/useEditProfile.test.ts new file mode 100644 index 0000000000..5bd466bc0d --- /dev/null +++ b/packages/lit-components/src/components/EditProfile/useEditProfile.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const updateProfileMock = vi.hoisted(() => vi.fn()); + +const componentBoilerplateMock = vi.hoisted(() => ({ + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { viewer: { firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com' } }, + loading: false, + })), + useMutation: vi.fn(() => [updateProfileMock]), +})); + +const universalHooksMock = vi.hoisted(() => ({ + setters: [] as Array>, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + universalHooksMock.setters.push(setter); + return [initial, setter] as const; + }), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useEditProfile } from './useEditProfile'; + +describe('useEditProfile', () => { + beforeEach(() => { + updateProfileMock.mockReset(); + updateProfileMock.mockResolvedValue({}); + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + componentBoilerplateMock.useUserIdentity.mockReturnValue({ jwt: 'test-jwt' }); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com' } }, + loading: false, + }); + componentBoilerplateMock.useMutation.mockReturnValue([updateProfileMock]); + }); + + it('returns initial state from query data', () => { + const result = useEditProfile({} as any); + + expect(result.firstName).toBe('Jane'); + expect(result.lastName).toBe('Doe'); + expect(result.email).toBe('jane@example.com'); + }); + + it('prevents default and calls the update mutation on submit', async () => { + const result = useEditProfile({} as any); + const preventDefault = vi.fn(); + + await result.onSubmit({ preventDefault } as any); + + expect(preventDefault).toHaveBeenCalled(); + expect(updateProfileMock).toHaveBeenCalledWith({ firstName: 'Jane', lastName: 'Doe' }); + }); + + it('handles mutation errors in onSubmit', async () => { + updateProfileMock.mockRejectedValue(new Error('nope')); + const result = useEditProfile({} as any); + + await result.onSubmit({ preventDefault: vi.fn() } as any); + + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('Failed to update profile. Please try again.'); + }); + + it('returns the query loading state', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useEditProfile({} as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/EditProfile/useEditProfile.ts b/packages/lit-components/src/components/EditProfile/useEditProfile.ts new file mode 100644 index 0000000000..f43c313b23 --- /dev/null +++ b/packages/lit-components/src/components/EditProfile/useEditProfile.ts @@ -0,0 +1,93 @@ +import { useMutation, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import type { EditProfileProps } from './EditProfile'; + +const PROFILE_QUERY = gql` + query getProfile { + viewer { + ... on User { + firstName + lastName + email + } + } + } +`; + +const UPDATE_PROFILE = gql` + mutation updateProfile($firstName: String, $lastName: String) { + updateUser(userInput: { firstName: $firstName, lastName: $lastName }) { + firstName + lastName + } + } +`; + +export function useEditProfile(_props: EditProfileProps) { + const user = useUserIdentity(); + const { data, loading } = useQuery(PROFILE_QUERY, {}, !user?.jwt); + const [updateProfile] = useMutation(UPDATE_PROFILE); + + const [firstName, setFirstName] = useState(data?.viewer?.firstName || ''); + const [lastName, setLastName] = useState(data?.viewer?.lastName || ''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [saving, setSaving] = useState(false); + + async function onSubmit(e: Event) { + e.preventDefault(); + setSaving(true); + setError(''); + setSuccess(false); + try { + await updateProfile({ firstName, lastName }); + setSuccess(true); + } catch (_err) { + setError('Failed to update profile. Please try again.'); + } + setSaving(false); + } + + return { + firstName, + setFirstName, + lastName, + setLastName, + email: data?.viewer?.email || '', + error, + success, + loading, + saving, + onSubmit, + }; +} + +export function useDemoEditProfile(_props: EditProfileProps): ReturnType { + const [firstName, setFirstName] = useState('Joe'); + const [lastName, setLastName] = useState('Smith'); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [saving, setSaving] = useState(false); + + async function onSubmit(event: Event) { + event.preventDefault(); + setSaving(true); + setError(''); + setSuccess(true); + setSaving(false); + } + + return { + firstName, + setFirstName, + lastName, + setLastName, + email: 'jsmith@example.com', + error, + success, + loading: false, + saving, + onSubmit, + }; +} diff --git a/packages/lit-components/src/components/EmailVerification/EmailVerification.stories.ts b/packages/lit-components/src/components/EmailVerification/EmailVerification.stories.ts new file mode 100644 index 0000000000..feef11c19f --- /dev/null +++ b/packages/lit-components/src/components/EmailVerification/EmailVerification.stories.ts @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { EmailVerificationView } from './EmailVerificationView'; + +const meta: Meta = { + title: 'Components/EmailVerification', + component: 'sql-email-verification', + tags: ['autodocs'], + argTypes: { + 'header-text': { control: 'text' }, + 'description-text': { control: 'text' }, + 'resend-label': { control: 'text' }, + 'success-text': { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Check Your Email', + descriptionText: "We've sent a verification email. Click the link to verify.", + resendLabel: 'Resend Email', + successText: 'Verified!', +}; + +export const Default: Story = {}; + +export const Resent: Story = { + render: () => + EmailVerificationView({ + ...baseProps, + resent: true, + loading: false, + onResend: () => {}, + }), +}; diff --git a/packages/lit-components/src/components/EmailVerification/EmailVerification.ts b/packages/lit-components/src/components/EmailVerification/EmailVerification.ts new file mode 100644 index 0000000000..d3c1e658e0 --- /dev/null +++ b/packages/lit-components/src/components/EmailVerification/EmailVerification.ts @@ -0,0 +1,37 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { EmailVerificationView } from './EmailVerificationView'; +import { useEmailVerification } from './useEmailVerification'; + +export interface EmailVerificationProps { + headerText: string; + descriptionText: string; + resendLabel: string; + successText: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-email-verification': HTMLElement; + } +} + +export const EmailVerification = useComponent( + (host) => { + const rawProps = getProps(host); + const props: EmailVerificationProps = { + headerText: rawProps.headerText || 'Check Your Email', + descriptionText: + rawProps.descriptionText || + "We've sent a verification email. Click the link to verify.", + resendLabel: rawProps.resendLabel || 'Resend Email', + successText: rawProps.successText || 'Verified!', + }; + + const hookProps = useEmailVerification(props); + + return EmailVerificationView({ ...props, ...hookProps }); + }, + 'sql-email-verification', + ['header-text', 'description-text', 'resend-label', 'success-text'] as const +); diff --git a/packages/lit-components/src/components/EmailVerification/EmailVerificationView.ts b/packages/lit-components/src/components/EmailVerification/EmailVerificationView.ts new file mode 100644 index 0000000000..aaef06913d --- /dev/null +++ b/packages/lit-components/src/components/EmailVerification/EmailVerificationView.ts @@ -0,0 +1,43 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { EmailVerificationProps } from './EmailVerification'; +import { useEmailVerification } from './useEmailVerification'; + +export function EmailVerificationView( + props: EmailVerificationProps & ReturnType +) { + return html` + + + `; +} diff --git a/packages/lit-components/src/components/EmailVerification/index.ts b/packages/lit-components/src/components/EmailVerification/index.ts new file mode 100644 index 0000000000..74a1eca957 --- /dev/null +++ b/packages/lit-components/src/components/EmailVerification/index.ts @@ -0,0 +1 @@ +export * from './EmailVerification'; diff --git a/packages/lit-components/src/components/EmailVerification/useEmailVerification.test.ts b/packages/lit-components/src/components/EmailVerification/useEmailVerification.test.ts new file mode 100644 index 0000000000..6f76f39430 --- /dev/null +++ b/packages/lit-components/src/components/EmailVerification/useEmailVerification.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useEmailVerification } from './useEmailVerification'; + +describe('useEmailVerification', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = useEmailVerification({} as any); + + expect(result.resent).toBe(false); + expect(result.loading).toBe(false); + }); + + it('should dispatch the resend event and update state', () => { + const dispatchSpy = document.dispatchEvent as ReturnType; + const result = useEmailVerification({} as any); + + result.onResend(); + + expect(universalHooksMock.setters[1]).toHaveBeenNthCalledWith(1, true); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:email-resend'); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[1]).toHaveBeenNthCalledWith(2, false); + }); +}); diff --git a/packages/lit-components/src/components/EmailVerification/useEmailVerification.ts b/packages/lit-components/src/components/EmailVerification/useEmailVerification.ts new file mode 100644 index 0000000000..06da54d299 --- /dev/null +++ b/packages/lit-components/src/components/EmailVerification/useEmailVerification.ts @@ -0,0 +1,17 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { EmailVerificationProps } from './EmailVerification'; + +export function useEmailVerification(_props: EmailVerificationProps) { + const [resent, setResent] = useState(false); + const [loading, setLoading] = useState(false); + + function onResend() { + setLoading(true); + const event = new CustomEvent('sq:email-resend', { bubbles: true, composed: true }); + document.dispatchEvent(event); + setResent(true); + setLoading(false); + } + + return { resent, loading, onResend }; +} diff --git a/packages/lit-components/src/components/Empty/Empty.stories.ts b/packages/lit-components/src/components/Empty/Empty.stories.ts new file mode 100644 index 0000000000..d28a32890e --- /dev/null +++ b/packages/lit-components/src/components/Empty/Empty.stories.ts @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const defaultImage = + 'https://res.cloudinary.com/saasquatch/image/upload/v1644360953/squatch-assets/empty_leaderboard2.png'; + +const meta: Meta = { + title: 'Components/Empty', + component: 'sql-empty', + tags: ['autodocs'], + argTypes: { + 'empty-state-image': { control: 'text' }, + 'empty-state-header': { control: 'text' }, + 'empty-state-text': { control: 'text' }, + 'support-text': { control: 'text' }, + }, + render: (args) => html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomImage: Story = { + args: { + 'empty-state-image': + 'https://res.cloudinary.com/saasquatch/image/upload/v1715360191/squatch-assets/Leaderboard_Not_Available.svg', + }, +}; + +export const NoImage: Story = { + args: { + 'empty-state-image': '', + }, +}; + +export const CustomHeaderAndText: Story = { + args: { + 'empty-state-header': 'Nothing to show yet', + 'empty-state-text': 'Complete your first action to populate this section.', + 'support-text': 'Need help? Contact support.', + }, +}; diff --git a/packages/lit-components/src/components/Empty/Empty.ts b/packages/lit-components/src/components/Empty/Empty.ts new file mode 100644 index 0000000000..fe9b02146d --- /dev/null +++ b/packages/lit-components/src/components/Empty/Empty.ts @@ -0,0 +1,39 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { EmptyView } from './EmptyView'; + +export interface EmptyProps { + emptyStateImage?: string; + emptyStateHeader?: string; + emptyStateText?: string; + supportText?: string; +} + +const DEFAULT_EMPTY_STATE_IMAGE = + 'https://res.cloudinary.com/saasquatch/image/upload/v1644360953/squatch-assets/empty_leaderboard2.png'; + +declare global { + interface HTMLElementTagNameMap { + 'sql-empty': HTMLElement; + } +} + +export const Empty = useComponent( + (host) => { + if (!host.getAttribute('slot')) { + host.setAttribute('slot', 'empty'); + } + + const rawProps = getProps(host) as Partial; + const props: EmptyProps = { + emptyStateImage: rawProps.emptyStateImage !== undefined ? rawProps.emptyStateImage : DEFAULT_EMPTY_STATE_IMAGE, + emptyStateHeader: rawProps.emptyStateHeader, + emptyStateText: rawProps.emptyStateText, + supportText: rawProps.supportText, + }; + + return EmptyView(props); + }, + 'sql-empty', + ['empty-state-image', 'empty-state-header', 'empty-state-text', 'support-text'] as const +); diff --git a/packages/lit-components/src/components/Empty/EmptyView.ts b/packages/lit-components/src/components/Empty/EmptyView.ts new file mode 100644 index 0000000000..226f605c7f --- /dev/null +++ b/packages/lit-components/src/components/Empty/EmptyView.ts @@ -0,0 +1,51 @@ +import { html } from 'lit'; +import { EmptyProps } from './Empty'; + +export function EmptyView(props: EmptyProps) { + return html` + +
+ ${props.emptyStateImage + ? html`` + : ''} +
+

${props.emptyStateHeader || ''}

+

+ ${props.emptyStateText || ''}${props.supportText ? html` ${props.supportText}` : ''} +

+
+
+ `; +} diff --git a/packages/lit-components/src/components/Empty/index.ts b/packages/lit-components/src/components/Empty/index.ts new file mode 100644 index 0000000000..47dea78504 --- /dev/null +++ b/packages/lit-components/src/components/Empty/index.ts @@ -0,0 +1 @@ +export * from './Empty'; diff --git a/packages/lit-components/src/components/FormMessage/FormMessage.stories.ts b/packages/lit-components/src/components/FormMessage/FormMessage.stories.ts new file mode 100644 index 0000000000..a54d292c25 --- /dev/null +++ b/packages/lit-components/src/components/FormMessage/FormMessage.stories.ts @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/FormMessage', + component: 'sql-form-message', + tags: ['autodocs'], + argTypes: { + type: { control: 'select', options: ['success', 'error', 'warning', 'info'] }, + message: { control: 'text' }, + icon: { control: 'text' }, + closable: { control: 'boolean' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +export const InfoMessage: Story = { + args: { + type: 'info', + message: 'Helpful information for the user.', + }, +}; + +export const SuccessMessage: Story = { + args: { + type: 'success', + message: 'Your changes were saved successfully.', + }, +}; + +export const ErrorMessage: Story = { + args: { + type: 'error', + message: 'Something went wrong while saving.', + }, +}; + +export const WarningMessage: Story = { + args: { + type: 'warning', + message: 'Please review the highlighted fields.', + }, +}; + +export const Closable: Story = { + args: { + type: 'info', + message: 'This message can be dismissed.', + closable: true, + }, +}; + +export const WithCustomIcon: Story = { + args: { + type: 'success', + message: 'Custom icon applied to the alert.', + icon: 'check2-circle', + }, +}; diff --git a/packages/lit-components/src/components/FormMessage/FormMessage.ts b/packages/lit-components/src/components/FormMessage/FormMessage.ts new file mode 100644 index 0000000000..a97ccef655 --- /dev/null +++ b/packages/lit-components/src/components/FormMessage/FormMessage.ts @@ -0,0 +1,43 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { FormMessageView } from './FormMessageView'; + +export interface FormMessageProps { + type?: 'success' | 'error' | 'warning' | 'info'; + message?: string; + icon?: string; + closable?: boolean; +} + +const parseBoolean = (value: unknown, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-form-message': HTMLElement; + } +} + +export const FormMessage = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: FormMessageProps = { + type: + rawProps.type === 'success' || + rawProps.type === 'error' || + rawProps.type === 'warning' + ? rawProps.type + : 'info', + message: typeof rawProps.message === 'string' ? rawProps.message : '', + icon: typeof rawProps.icon === 'string' ? rawProps.icon : undefined, + closable: parseBoolean(rawProps.closable, false), + }; + + return FormMessageView(props); + }, + 'sql-form-message', + ['type', 'message', 'icon', 'closable'] as const +); diff --git a/packages/lit-components/src/components/FormMessage/FormMessageView.ts b/packages/lit-components/src/components/FormMessage/FormMessageView.ts new file mode 100644 index 0000000000..e7044ba70f --- /dev/null +++ b/packages/lit-components/src/components/FormMessage/FormMessageView.ts @@ -0,0 +1,25 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { AlertVariant } from '../../ui/types'; +import { FormMessageProps } from './FormMessage'; + +export function FormMessageView(props: FormMessageProps) { + return html` + + ${props.message + ? html` + ${UI.Alert({ + variant: (props.type === 'error' ? 'danger' : props.type === 'info' ? 'neutral' : props.type) as AlertVariant, + closable: props.closable, + open: true, + icon: props.icon ? UI.Icon({ name: props.icon }) : undefined, + children: props.message, + })} + ` + : ''} + `; +} diff --git a/packages/lit-components/src/components/FormMessage/index.ts b/packages/lit-components/src/components/FormMessage/index.ts new file mode 100644 index 0000000000..81e21f2826 --- /dev/null +++ b/packages/lit-components/src/components/FormMessage/index.ts @@ -0,0 +1 @@ +export * from './FormMessage'; diff --git a/packages/lit-components/src/components/GoogleSignIn/GoogleSignIn.stories.ts b/packages/lit-components/src/components/GoogleSignIn/GoogleSignIn.stories.ts new file mode 100644 index 0000000000..f6d70f9c5e --- /dev/null +++ b/packages/lit-components/src/components/GoogleSignIn/GoogleSignIn.stories.ts @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/GoogleSignIn', + component: 'sql-google-sign-in', + tags: ['autodocs'], + argTypes: { + text: { + control: 'select', + options: ['signup_with', 'signin_with', 'continue_with', 'signin'], + }, + 'client-id': { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const SignIn: Story = { + args: { + text: 'signin_with', + }, +}; + +export const Continue: Story = { + args: { + text: 'continue_with', + }, +}; diff --git a/packages/lit-components/src/components/GoogleSignIn/GoogleSignIn.ts b/packages/lit-components/src/components/GoogleSignIn/GoogleSignIn.ts new file mode 100644 index 0000000000..df951759d1 --- /dev/null +++ b/packages/lit-components/src/components/GoogleSignIn/GoogleSignIn.ts @@ -0,0 +1,32 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { GoogleSignInView } from './GoogleSignInView'; +import { useDemoGoogleSignIn, useGoogleSignIn } from './useGoogleSignIn'; + +export interface GoogleSignInProps { + text: string; + clientId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-google-sign-in': HTMLElement; + } +} + +export const GoogleSignIn = useComponent( + (host) => { + const rawProps = getProps(host); + const props: GoogleSignInProps = { + text: rawProps.text || 'signup_with', + clientId: rawProps.clientId || undefined, + }; + + const hookProps = isDemo() ? useDemoGoogleSignIn(host, props) : useGoogleSignIn(host, props); + + return GoogleSignInView({ ...props, ...hookProps }); + }, + 'sql-google-sign-in', + ['text', 'client-id'] as const +); diff --git a/packages/lit-components/src/components/GoogleSignIn/GoogleSignInView.ts b/packages/lit-components/src/components/GoogleSignIn/GoogleSignInView.ts new file mode 100644 index 0000000000..f3bee0bd4d --- /dev/null +++ b/packages/lit-components/src/components/GoogleSignIn/GoogleSignInView.ts @@ -0,0 +1,33 @@ +import { html, nothing } from 'lit'; +import { UI } from '../../ui'; +import type { GoogleSignInProps } from './GoogleSignIn'; +import type { GoogleSignInHookResult } from './useGoogleSignIn'; + +export function GoogleSignInView(props: GoogleSignInProps & GoogleSignInHookResult) { + return html` + + + `; +} diff --git a/packages/lit-components/src/components/GoogleSignIn/index.ts b/packages/lit-components/src/components/GoogleSignIn/index.ts new file mode 100644 index 0000000000..100d301857 --- /dev/null +++ b/packages/lit-components/src/components/GoogleSignIn/index.ts @@ -0,0 +1 @@ +export * from './GoogleSignIn'; diff --git a/packages/lit-components/src/components/GoogleSignIn/useGoogleSignIn.test.ts b/packages/lit-components/src/components/GoogleSignIn/useGoogleSignIn.test.ts new file mode 100644 index 0000000000..67edc1f6ca --- /dev/null +++ b/packages/lit-components/src/components/GoogleSignIn/useGoogleSignIn.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + getEnvironmentSDK: vi.fn(() => ({ env: { googleOAuthId: undefined } })), +})); + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useEffect: vi.fn((callback: () => void) => callback()), + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useEffect: universalHooksMock.useEffect, + useState: universalHooksMock.useState, +})); + +import { useGoogleSignIn } from './useGoogleSignIn'; + +describe('useGoogleSignIn', () => { + const createHost = (container: HTMLElement | null = null) => ({ + dispatchEvent: vi.fn(), + shadowRoot: { + querySelector: vi.fn(() => container), + }, + }) as unknown as HTMLElement; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + componentBoilerplateMock.getEnvironmentSDK.mockReturnValue({ env: { googleOAuthId: undefined } }); + + const head = { + querySelector: vi.fn(() => null), + appendChild: vi.fn(), + }; + + vi.stubGlobal('document', { + head, + createElement: vi.fn(() => ({ + src: '', + async: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); + vi.stubGlobal('window', { + clearTimeout, + setTimeout, + }); + }); + + it('should show the fallback button when no client id is available', () => { + const result = useGoogleSignIn(createHost(), { text: 'continue_with' }); + + expect(result.showFallbackButton).toBe(true); + expect(result.fallbackLabel).toBe('Continue with Google'); + }); + + it('should initialize the google button when the api and client id are available', () => { + const initialize = vi.fn(); + const renderButton = vi.fn(); + const container = { innerHTML: 'filled', clientWidth: 320 } as HTMLElement; + const windowMock = { + clearTimeout, + setTimeout, + google: { accounts: { id: { initialize, renderButton } } }, + }; + vi.stubGlobal('window', windowMock); + + const result = useGoogleSignIn(createHost(container), { + text: 'signin_with', + clientId: 'google-client-id', + }); + + expect(result.showFallbackButton).toBe(false); + expect(result.fallbackLabel).toBe('Sign in with Google'); + expect(initialize).toHaveBeenCalledWith({ + client_id: 'google-client-id', + callback: expect.any(Function), + }); + expect(renderButton).toHaveBeenCalledWith( + container, + expect.objectContaining({ text: 'signin_with', width: 400 }) + ); + }); + + it('should dispatch init-complete from the fallback button', () => { + const host = createHost(); + const result = useGoogleSignIn(host, { text: 'signup_with', clientId: 'google-client-id' }); + + result.onFallbackClick(); + + expect(host.dispatchEvent).toHaveBeenCalledTimes(1); + const event = (host.dispatchEvent as ReturnType).mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('init-complete'); + expect(event.detail).toEqual({ credential: null, clientId: 'google-client-id' }); + }); +}); diff --git a/packages/lit-components/src/components/GoogleSignIn/useGoogleSignIn.ts b/packages/lit-components/src/components/GoogleSignIn/useGoogleSignIn.ts new file mode 100644 index 0000000000..125a2bae68 --- /dev/null +++ b/packages/lit-components/src/components/GoogleSignIn/useGoogleSignIn.ts @@ -0,0 +1,192 @@ +import { getEnvironmentSDK } from '@saasquatch/component-boilerplate'; +import { useEffect, useState } from '@saasquatch/universal-hooks'; +import type { GoogleSignInProps } from './GoogleSignIn'; + +const GOOGLE_SCRIPT_SRC = 'https://accounts.google.com/gsi/client'; + +interface GoogleCredentialResponse { + credential?: string | null; + select_by?: string; + clientId?: string; +} + +interface GoogleAccountsApi { + id: { + initialize: (options: { + client_id: string; + callback: (response: GoogleCredentialResponse) => void; + }) => void; + renderButton: (element: HTMLElement, options: Record) => void; + }; +} + +declare global { + interface Window { + google?: { accounts?: GoogleAccountsApi }; + } +} + +export interface GoogleSignInHookResult { + showFallbackButton: boolean; + fallbackLabel: string; + onFallbackClick: () => void; +} + +function normalizeGoogleText(text?: string) { + if (text === 'signup_with' || text === 'signin_with' || text === 'continue_with' || text === 'signin') { + return text; + } + return 'continue_with'; +} + +function getFallbackLabel(text?: string) { + switch (text) { + case 'signup_with': + return 'Sign up with Google'; + case 'signin_with': + return 'Sign in with Google'; + case 'signin': + return 'Sign in'; + case 'continue_with': + return 'Continue with Google'; + default: + return text || 'Continue with Google'; + } +} + +function getResolvedClientId(props: GoogleSignInProps) { + if (props.clientId) { + return props.clientId; + } + + try { + const sdk = getEnvironmentSDK() as { env?: { googleOAuthId?: string } }; + return sdk?.env?.googleOAuthId; + } catch { + return undefined; + } +} + +function emitInitComplete(host: HTMLElement, detail: GoogleCredentialResponse) { + host.dispatchEvent( + new CustomEvent('init-complete', { + bubbles: true, + composed: true, + detail, + }) + ); +} + +export function useGoogleSignIn(host: HTMLElement, props: GoogleSignInProps): GoogleSignInHookResult { + const [loaded, setLoaded] = useState( + typeof window !== 'undefined' && Boolean(window.google?.accounts?.id) + ); + const [buttonWidth, setButtonWidth] = useState(400); + const normalizedText = normalizeGoogleText(props.text); + const fallbackLabel = getFallbackLabel(props.text); + const clientId = getResolvedClientId(props); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + if (window.google?.accounts?.id) { + setLoaded(true); + return; + } + + const existing = document.head.querySelector(`script[src="${GOOGLE_SCRIPT_SRC}"]`) as + | HTMLScriptElement + | null; + + const handleLoad = () => setLoaded(true); + + if (existing) { + existing.addEventListener('load', handleLoad); + return () => existing.removeEventListener('load', handleLoad); + } + + const script = document.createElement('script'); + script.src = GOOGLE_SCRIPT_SRC; + script.async = true; + script.addEventListener('load', handleLoad); + document.head.appendChild(script); + + return () => { + script.removeEventListener('load', handleLoad); + }; + }, []); + + useEffect(() => { + if (typeof ResizeObserver === 'undefined') { + return; + } + + const container = host.shadowRoot?.querySelector('.google-sign-in-button') as HTMLElement | null; + if (!container) { + return; + } + + let timeoutId: number | undefined; + const updateWidth = () => setButtonWidth(container.clientWidth || 400); + updateWidth(); + + const observer = new ResizeObserver(() => { + if (timeoutId) { + window.clearTimeout(timeoutId); + } + timeoutId = window.setTimeout(updateWidth, 50); + }); + + observer.observe(container); + + return () => { + if (timeoutId) { + window.clearTimeout(timeoutId); + } + observer.disconnect(); + }; + }, [host]); + + useEffect(() => { + const container = host.shadowRoot?.querySelector('.google-sign-in-button') as HTMLElement | null; + const google = window.google; + + if (!container || !loaded || !clientId || !google?.accounts?.id) { + return; + } + + container.innerHTML = ''; + google.accounts.id.initialize({ + client_id: clientId, + callback: (response) => emitInitComplete(host, response), + }); + google.accounts.id.renderButton(container, { + theme: 'outline', + size: 'large', + text: normalizedText, + height: 40, + width: buttonWidth, + }); + }, [host, loaded, clientId, normalizedText, buttonWidth]); + + return { + showFallbackButton: !loaded || !clientId, + fallbackLabel, + onFallbackClick: () => emitInitComplete(host, { credential: null, clientId }), + }; +} + +export function useDemoGoogleSignIn( + host: HTMLElement, + props: GoogleSignInProps +): GoogleSignInHookResult { + const fallbackLabel = getFallbackLabel(props.text); + + return { + showFallbackButton: true, + fallbackLabel, + onFallbackClick: () => emitInitComplete(host, { credential: null, clientId: props.clientId || 'demo-client-id' }), + }; +} diff --git a/packages/lit-components/src/components/GraphQLClientProvider/GraphQLClientProvider.stories.ts b/packages/lit-components/src/components/GraphQLClientProvider/GraphQLClientProvider.stories.ts new file mode 100644 index 0000000000..db38725afe --- /dev/null +++ b/packages/lit-components/src/components/GraphQLClientProvider/GraphQLClientProvider.stories.ts @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/GraphQLClientProvider', + component: 'sql-graphql-client-provider', + tags: ['autodocs'], + render: () => html` + +

Wrapped content

+
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/lit-components/src/components/GraphQLClientProvider/GraphQLClientProvider.ts b/packages/lit-components/src/components/GraphQLClientProvider/GraphQLClientProvider.ts new file mode 100644 index 0000000000..c020dc94e7 --- /dev/null +++ b/packages/lit-components/src/components/GraphQLClientProvider/GraphQLClientProvider.ts @@ -0,0 +1,28 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { GraphQLClientProviderView } from './GraphQLClientProviderView'; + +export interface GraphQLClientProviderProps { + domain?: string; + tenantAlias?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-graphql-client-provider': HTMLElement; + } +} + +export const GraphQLClientProvider = useComponent( + (host) => { + const rawProps = getProps(host); + const props: GraphQLClientProviderProps = { + domain: rawProps.domain || undefined, + tenantAlias: rawProps.tenantAlias || undefined, + }; + + return GraphQLClientProviderView(props); + }, + 'sql-graphql-client-provider', + ['domain', 'tenant-alias'] as const +); diff --git a/packages/lit-components/src/components/GraphQLClientProvider/GraphQLClientProviderView.ts b/packages/lit-components/src/components/GraphQLClientProvider/GraphQLClientProviderView.ts new file mode 100644 index 0000000000..b29131aea6 --- /dev/null +++ b/packages/lit-components/src/components/GraphQLClientProvider/GraphQLClientProviderView.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +import type { GraphQLClientProviderProps } from './GraphQLClientProvider'; + +export function GraphQLClientProviderView(_props: GraphQLClientProviderProps) { + return html` + + + `; +} diff --git a/packages/lit-components/src/components/GraphQLClientProvider/index.ts b/packages/lit-components/src/components/GraphQLClientProvider/index.ts new file mode 100644 index 0000000000..d66b9aef40 --- /dev/null +++ b/packages/lit-components/src/components/GraphQLClientProvider/index.ts @@ -0,0 +1 @@ +export * from './GraphQLClientProvider'; diff --git a/packages/lit-components/src/components/HeaderLogo/HeaderLogo.stories.ts b/packages/lit-components/src/components/HeaderLogo/HeaderLogo.stories.ts new file mode 100644 index 0000000000..9c14e116c9 --- /dev/null +++ b/packages/lit-components/src/components/HeaderLogo/HeaderLogo.stories.ts @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const defaultImage = + 'https://res.cloudinary.com/saasquatch/image/upload/v1644000259/squatch-assets/tn47wOj.png'; + +const meta: Meta = { + title: 'Components/HeaderLogo', + component: 'sql-header-logo', + tags: ['autodocs'], + argTypes: { + 'image-url': { control: 'text' }, + height: { control: 'text' }, + href: { control: 'text' }, + alignment: { control: 'select', options: ['left', 'center', 'right'] }, + }, + render: (args) => + html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Centered: Story = { + args: { + alignment: 'center', + }, +}; + +export const WithLink: Story = { + args: { + href: 'https://www.saasquatch.com', + }, +}; + +export const CustomHeight: Story = { + args: { + height: '56px', + }, +}; diff --git a/packages/lit-components/src/components/HeaderLogo/HeaderLogo.ts b/packages/lit-components/src/components/HeaderLogo/HeaderLogo.ts new file mode 100644 index 0000000000..dd5bbb8a3d --- /dev/null +++ b/packages/lit-components/src/components/HeaderLogo/HeaderLogo.ts @@ -0,0 +1,33 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { HeaderLogoView } from './HeaderLogoView'; + +export interface HeaderLogoProps { + imageUrl: string; + height: string; + href?: string; + alignment: 'left' | 'center' | 'right'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-header-logo': HTMLElement; + } +} + +export const HeaderLogo = useComponent( + (host) => { + const rawProps = getProps(host) as Partial; + const alignment = rawProps.alignment; + const props: HeaderLogoProps = { + imageUrl: rawProps.imageUrl || '', + height: rawProps.height || '40px', + href: rawProps.href, + alignment: alignment === 'center' || alignment === 'right' ? alignment : 'left', + }; + + return HeaderLogoView(props); + }, + 'sql-header-logo', + ['image-url', 'height', 'href', 'alignment'] as const +); diff --git a/packages/lit-components/src/components/HeaderLogo/HeaderLogoView.ts b/packages/lit-components/src/components/HeaderLogo/HeaderLogoView.ts new file mode 100644 index 0000000000..2169f9fa75 --- /dev/null +++ b/packages/lit-components/src/components/HeaderLogo/HeaderLogoView.ts @@ -0,0 +1,39 @@ +import { html } from 'lit'; +import { HeaderLogoProps } from './HeaderLogo'; + +const justifyMap = { + left: 'flex-start', + center: 'center', + right: 'flex-end', +} as const; + +export function HeaderLogoView(props: HeaderLogoProps) { + return html` + + + `; +} diff --git a/packages/lit-components/src/components/HeaderLogo/index.ts b/packages/lit-components/src/components/HeaderLogo/index.ts new file mode 100644 index 0000000000..b2de406985 --- /dev/null +++ b/packages/lit-components/src/components/HeaderLogo/index.ts @@ -0,0 +1 @@ +export * from './HeaderLogo'; diff --git a/packages/lit-components/src/components/Hero/Hero.stories.ts b/packages/lit-components/src/components/Hero/Hero.stories.ts new file mode 100644 index 0000000000..caed3954c3 --- /dev/null +++ b/packages/lit-components/src/components/Hero/Hero.stories.ts @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { UI } from '../../ui'; +import '../../index'; + +const meta: Meta = { + title: 'Components/Hero', + component: 'sql-hero', + tags: ['autodocs'], + argTypes: { columns: { control: 'number' }, 'min-height': { control: 'text' }, background: { control: 'text' }, 'secondary-background': { control: 'text' }, 'padding-size': { control: 'select', options: ['none', 'small', 'medium', 'large', 'x-large', 'xx-large'] }, 'wrap-direction': { control: 'select', options: ['', 'reverse'] }, 'column-to-hide-in-mobile': { control: 'number' } }, + render: (args) => html` + +
+

Grow faster with referrals

+

Launch a branded referral campaign in minutes.

+ ${UI.Button({ variant: 'primary', children: 'Get started' })} +
+ ${Number(args.columns || 2) === 2 + ? html`

Built for teams

Combine acquisition, engagement, and rewards in one experience.

` + : ''} +
+ `, +}; +export default meta; +type Story = StoryObj; +export const OneColumn: Story = { args: { columns: 1, background: 'linear-gradient(135deg, #dbeafe, #eff6ff)' } }; +export const TwoColumns: Story = { args: { columns: 2 } }; +export const CustomBackground: Story = { args: { background: 'linear-gradient(135deg, #0f172a, #1d4ed8)', 'secondary-background': 'linear-gradient(135deg, #ffffff, #dbeafe)' } }; +export const CustomPadding: Story = { args: { 'padding-size': 'xx-large' } }; +export const WrapReverse: Story = { args: { 'wrap-direction': 'reverse' } }; +export const HideColumnOnMobile: Story = { args: { 'column-to-hide-in-mobile': 2 } }; diff --git a/packages/lit-components/src/components/Hero/Hero.ts b/packages/lit-components/src/components/Hero/Hero.ts new file mode 100644 index 0000000000..fb9c1caae7 --- /dev/null +++ b/packages/lit-components/src/components/Hero/Hero.ts @@ -0,0 +1,51 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { HeroView } from './HeroView'; + +export type HeroPaddingSize = 'none' | 'small' | 'medium' | 'large' | 'x-large' | 'xx-large'; + +export interface HeroProps { + columns: number; + minHeight?: string; + background?: string; + paddingSize: HeroPaddingSize; + secondaryBackground?: string; + wrapDirection: 'reverse' | ''; + columnToHideInMobile: number; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-hero': HTMLElement; + } +} + +export const Hero = useComponent( + (host) => { + const rawProps = getProps(host); + const parsedColumns = Number(rawProps.columns); + const parsedColumnToHide = Number(rawProps.columnToHideInMobile); + + const props: HeroProps = { + columns: parsedColumns === 1 ? 1 : 2, + minHeight: rawProps.minHeight, + background: rawProps.background, + paddingSize: (rawProps.paddingSize as HeroPaddingSize) || 'large', + secondaryBackground: rawProps.secondaryBackground, + wrapDirection: rawProps.wrapDirection === 'reverse' ? 'reverse' : '', + columnToHideInMobile: Number.isNaN(parsedColumnToHide) ? 0 : parsedColumnToHide, + }; + + return HeroView(props); + }, + 'sql-hero', + [ + 'columns', + 'min-height', + 'background', + 'padding-size', + 'secondary-background', + 'wrap-direction', + 'column-to-hide-in-mobile', + ] as const +); diff --git a/packages/lit-components/src/components/Hero/HeroView.ts b/packages/lit-components/src/components/Hero/HeroView.ts new file mode 100644 index 0000000000..40cbb8a8df --- /dev/null +++ b/packages/lit-components/src/components/Hero/HeroView.ts @@ -0,0 +1,65 @@ +import { html } from 'lit'; +import { HeroProps } from './Hero'; + +function getPaddingValue(paddingSize: HeroProps['paddingSize']) { + return paddingSize === 'none' ? '0' : `var(--sl-spacing-${paddingSize})`; +} + +export function HeroView(props: HeroProps) { + const columnTemplate = (slotName: '1' | '2', extraClass = '', background = 'transparent') => html` +
+ +
+ `; + + return html` + +
+ ${props.columns === 1 + ? columnTemplate('1', '', props.background || 'transparent') + : html` + ${columnTemplate( + '1', + props.columnToHideInMobile === 1 ? 'hide-mobile' : '', + props.background || 'transparent' + )} + ${columnTemplate( + '2', + props.columnToHideInMobile === 2 ? 'hide-mobile' : '', + props.secondaryBackground || 'transparent' + )} + `} +
+ `; +} diff --git a/packages/lit-components/src/components/Hero/index.ts b/packages/lit-components/src/components/Hero/index.ts new file mode 100644 index 0000000000..729bca95fb --- /dev/null +++ b/packages/lit-components/src/components/Hero/index.ts @@ -0,0 +1 @@ +export * from './Hero'; diff --git a/packages/lit-components/src/components/HeroImage/HeroImage.stories.ts b/packages/lit-components/src/components/HeroImage/HeroImage.stories.ts new file mode 100644 index 0000000000..6d03f4035d --- /dev/null +++ b/packages/lit-components/src/components/HeroImage/HeroImage.stories.ts @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const imageUrl = + 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=1200&q=80'; + +const meta: Meta = { + title: 'Components/HeroImage', + component: 'sql-hero-image', + tags: ['autodocs'], + argTypes: { + 'image-url': { control: 'text' }, + layout: { control: 'select', options: ['overlay', 'columns'] }, + header: { control: 'text' }, + description: { control: 'text' }, + 'button-text': { control: 'text' }, + 'button-link': { control: 'text' }, + 'image-pos': { control: 'select', options: ['left', 'right'] }, + 'overlay-opacity': { control: 'number' }, + 'overlay-color': { control: 'color' }, + 'text-color': { control: 'color' }, + 'image-min-height': { control: 'text' }, + 'image-padding': { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +export const OverlayLayout: Story = { + args: { + layout: 'overlay', + }, +}; + +export const ColumnsLayout: Story = { + args: { + layout: 'columns', + 'text-color': '#0f172a', + }, +}; + +export const ImageOnRight: Story = { + args: { + layout: 'columns', + 'image-pos': 'right', + 'text-color': '#0f172a', + }, +}; + +export const CustomOverlayOpacity: Story = { + args: { + layout: 'overlay', + 'overlay-opacity': 0.45, + }, +}; + +export const CustomTextColor: Story = { + args: { + layout: 'overlay', + 'text-color': '#fde68a', + }, +}; + +export const WithButton: Story = { + args: { + layout: 'overlay', + 'button-text': 'Learn more', + 'button-link': 'https://example.com', + }, +}; diff --git a/packages/lit-components/src/components/HeroImage/HeroImage.ts b/packages/lit-components/src/components/HeroImage/HeroImage.ts new file mode 100644 index 0000000000..cf5ee961e2 --- /dev/null +++ b/packages/lit-components/src/components/HeroImage/HeroImage.ts @@ -0,0 +1,61 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { HeroImageView } from './HeroImageView'; + +export interface HeroImageProps { + imageUrl: string; + layout: 'overlay' | 'columns'; + header?: string; + description?: string; + buttonText?: string; + buttonLink?: string; + imagePos: 'left' | 'right'; + overlayOpacity: number; + overlayColor: string; + textColor: string; + imageMinHeight?: string; + imagePadding?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-hero-image': HTMLElement; + } +} + +export const HeroImage = useComponent( + (host) => { + const rawProps = getProps(host); + const props: HeroImageProps = { + imageUrl: rawProps.imageUrl || '', + layout: rawProps.layout === 'overlay' ? 'overlay' : 'columns', + header: rawProps.header, + description: rawProps.description, + buttonText: rawProps.buttonText, + buttonLink: rawProps.buttonLink, + imagePos: rawProps.imagePos === 'right' ? 'right' : 'left', + overlayOpacity: rawProps.overlayOpacity ? Number(rawProps.overlayOpacity) : 0.75, + overlayColor: rawProps.overlayColor || '#000000', + textColor: rawProps.textColor || '#ffffff', + imageMinHeight: rawProps.imageMinHeight, + imagePadding: rawProps.imagePadding, + }; + + return HeroImageView(props); + }, + 'sql-hero-image', + [ + 'image-url', + 'layout', + 'header', + 'description', + 'button-text', + 'button-link', + 'image-pos', + 'overlay-opacity', + 'overlay-color', + 'text-color', + 'image-min-height', + 'image-padding', + ] as const +); diff --git a/packages/lit-components/src/components/HeroImage/HeroImageView.ts b/packages/lit-components/src/components/HeroImage/HeroImageView.ts new file mode 100644 index 0000000000..f3d1f0b001 --- /dev/null +++ b/packages/lit-components/src/components/HeroImage/HeroImageView.ts @@ -0,0 +1,126 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { HeroImageProps } from './HeroImage'; + +function renderButton(props: HeroImageProps) { + if (!props.buttonText) { + return ''; + } + + return UI.Button({ + variant: 'primary', + href: props.buttonLink || '', + disabled: !props.buttonLink, + pill: props.layout === 'overlay', + children: props.buttonText, + }); +} + +export function HeroImageView(props: HeroImageProps) { + return html` + + ${props.layout === 'overlay' + ? html` +
+ ${props.header || 'Hero image'} + +
+ ${props.header ? html`

${props.header}

` : ''} + ${props.description ? html`

${props.description}

` : ''} + ${renderButton(props)} +
+
+ ` + : html` +
+
+ ${props.header || 'Hero image'} +
+
+ ${props.header ? html`

${props.header}

` : ''} + ${props.description ? html`

${props.description}

` : ''} + ${renderButton(props)} +
+
+ `} + `; +} diff --git a/packages/lit-components/src/components/HeroImage/index.ts b/packages/lit-components/src/components/HeroImage/index.ts new file mode 100644 index 0000000000..ab833f3eb5 --- /dev/null +++ b/packages/lit-components/src/components/HeroImage/index.ts @@ -0,0 +1 @@ +export * from './HeroImage'; diff --git a/packages/lit-components/src/components/Image/Image.stories.ts b/packages/lit-components/src/components/Image/Image.stories.ts new file mode 100644 index 0000000000..40413d7b0a --- /dev/null +++ b/packages/lit-components/src/components/Image/Image.stories.ts @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/Image', + component: 'sql-image', + tags: ['autodocs'], + argTypes: { + 'image-url': { control: 'text' }, + alignment: { control: 'select', options: ['left', 'center', 'right'] }, + 'background-color': { control: 'color' }, + 'min-height': { control: 'text' }, + width: { control: 'text' }, + }, + render: (args) => html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const LeftAligned: Story = { + args: { + alignment: 'left', + }, +}; + +export const RightAligned: Story = { + args: { + alignment: 'right', + }, +}; + +export const CustomBackground: Story = { + args: { + 'background-color': '#f5f5f5', + }, +}; + +export const CustomSize: Story = { + args: { + 'min-height': '180px', + width: '240px', + }, +}; + +export const CustomWidth: Story = { + args: { + width: '50%', + }, +}; diff --git a/packages/lit-components/src/components/Image/Image.ts b/packages/lit-components/src/components/Image/Image.ts new file mode 100644 index 0000000000..0d3c648e1a --- /dev/null +++ b/packages/lit-components/src/components/Image/Image.ts @@ -0,0 +1,37 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ImageView } from './ImageView'; + +export interface ImageProps { + imageUrl: string; + alignment?: 'left' | 'center' | 'right'; + backgroundColor?: string; + minHeight?: string; + width?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-image': HTMLElement; + } +} + +export const Image = useComponent( + (host) => { + const rawProps = getProps(host) as Partial; + const props: ImageProps = { + imageUrl: rawProps.imageUrl || '', + alignment: + rawProps.alignment === 'left' || rawProps.alignment === 'right' || rawProps.alignment === 'center' + ? rawProps.alignment + : 'center', + backgroundColor: rawProps.backgroundColor, + minHeight: rawProps.minHeight, + width: rawProps.width, + }; + + return ImageView(props); + }, + 'sql-image', + ['image-url', 'alignment', 'background-color', 'min-height', 'width'] as const +); diff --git a/packages/lit-components/src/components/Image/ImageView.ts b/packages/lit-components/src/components/Image/ImageView.ts new file mode 100644 index 0000000000..20e7660fac --- /dev/null +++ b/packages/lit-components/src/components/Image/ImageView.ts @@ -0,0 +1,33 @@ +import { html } from 'lit'; +import { ImageProps } from './Image'; + +export function ImageView(props: ImageProps) { + const alignment = props.alignment || 'center'; + const justifyContent = + alignment === 'left' ? 'flex-start' : alignment === 'right' ? 'flex-end' : 'center'; + + return html` + +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/Image/index.ts b/packages/lit-components/src/components/Image/index.ts new file mode 100644 index 0000000000..4bbac90149 --- /dev/null +++ b/packages/lit-components/src/components/Image/index.ts @@ -0,0 +1 @@ +export * from './Image'; diff --git a/packages/lit-components/src/components/IndirectTaxForm/IndirectTaxForm.stories.ts b/packages/lit-components/src/components/IndirectTaxForm/IndirectTaxForm.stories.ts new file mode 100644 index 0000000000..611572487b --- /dev/null +++ b/packages/lit-components/src/components/IndirectTaxForm/IndirectTaxForm.stories.ts @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { IndirectTaxFormView } from './IndirectTaxFormView'; + +const meta: Meta = { + title: 'Components/IndirectTaxForm', + component: 'sql-indirect-tax-form', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Tax Information', + submitLabel: 'Submit', + taxIdLabel: 'Tax ID / VAT Number', + countryLabel: 'Country', + programId: 'demo-program', + taxId: 'VAT-123456', + setTaxId: () => undefined, + country: 'Canada', + setCountry: () => undefined, + error: '', + loading: false, + success: false, + onSubmit: (event: Event) => event.preventDefault(), +}; + +export const Default: Story = { + render: () => IndirectTaxFormView(baseProps), +}; + +export const WithError: Story = { + render: () => + IndirectTaxFormView({ + ...baseProps, + error: 'Tax ID is required', + }), +}; + +export const Success: Story = { + render: () => + IndirectTaxFormView({ + ...baseProps, + success: true, + }), +}; + +export const Loading: Story = { + render: () => + IndirectTaxFormView({ + ...baseProps, + loading: true, + }), +}; diff --git a/packages/lit-components/src/components/IndirectTaxForm/IndirectTaxForm.ts b/packages/lit-components/src/components/IndirectTaxForm/IndirectTaxForm.ts new file mode 100644 index 0000000000..9112acd416 --- /dev/null +++ b/packages/lit-components/src/components/IndirectTaxForm/IndirectTaxForm.ts @@ -0,0 +1,38 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { IndirectTaxFormView } from './IndirectTaxFormView'; +import { useDemoIndirectTaxForm, useIndirectTaxForm } from './useIndirectTaxForm'; + +export interface IndirectTaxFormProps { + headerText: string; + submitLabel: string; + taxIdLabel: string; + countryLabel: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-indirect-tax-form': HTMLElement; + } +} + +export const IndirectTaxForm = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: IndirectTaxFormProps = { + headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Tax Information', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Submit', + taxIdLabel: typeof rawProps.taxIdLabel === 'string' ? rawProps.taxIdLabel : 'Tax ID / VAT Number', + countryLabel: typeof rawProps.countryLabel === 'string' ? rawProps.countryLabel : 'Country', + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + }; + + const hookProps = isDemo() ? useDemoIndirectTaxForm(props) : useIndirectTaxForm(props); + + return IndirectTaxFormView({ ...props, ...hookProps }); + }, + 'sql-indirect-tax-form', + ['header-text', 'submit-label', 'tax-id-label', 'country-label', 'program-id'] as const +); diff --git a/packages/lit-components/src/components/IndirectTaxForm/IndirectTaxFormView.ts b/packages/lit-components/src/components/IndirectTaxForm/IndirectTaxFormView.ts new file mode 100644 index 0000000000..1a5a46fd30 --- /dev/null +++ b/packages/lit-components/src/components/IndirectTaxForm/IndirectTaxFormView.ts @@ -0,0 +1,27 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { IndirectTaxFormProps } from './IndirectTaxForm'; +import { useIndirectTaxForm } from './useIndirectTaxForm'; + +const getInputValue = (event: Event) => ((event.target as HTMLInputElement & { value: string })?.value ?? ''); + +export function IndirectTaxFormView(props: IndirectTaxFormProps & ReturnType) { + return html` + +
+

${props.headerText}

+ ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} + ${props.success ? html`${UI.Alert({ variant: 'success', open: true, children: 'Tax information submitted.' })}` : ''} +
+ ${UI.Input({ label: props.taxIdLabel, value: props.taxId, onInput: (event: Event) => props.setTaxId(getInputValue(event)), disabled: props.loading })} + ${UI.Input({ label: props.countryLabel, value: props.country, onInput: (event: Event) => props.setCountry(getInputValue(event)), disabled: props.loading })} + ${UI.Button({ type: 'submit', variant: 'primary', loading: props.loading, children: props.submitLabel })} +
+
+ `; +} diff --git a/packages/lit-components/src/components/IndirectTaxForm/index.ts b/packages/lit-components/src/components/IndirectTaxForm/index.ts new file mode 100644 index 0000000000..321761fca0 --- /dev/null +++ b/packages/lit-components/src/components/IndirectTaxForm/index.ts @@ -0,0 +1 @@ +export * from './IndirectTaxForm'; diff --git a/packages/lit-components/src/components/IndirectTaxForm/useIndirectTaxForm.test.ts b/packages/lit-components/src/components/IndirectTaxForm/useIndirectTaxForm.test.ts new file mode 100644 index 0000000000..ecd8f6a2f2 --- /dev/null +++ b/packages/lit-components/src/components/IndirectTaxForm/useIndirectTaxForm.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), +})); + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useIndirectTaxForm } from './useIndirectTaxForm'; + +describe('useIndirectTaxForm', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + componentBoilerplateMock.useProgramId.mockReturnValue('test-program'); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = useIndirectTaxForm({ programId: '' } as any); + + expect(result.taxId).toBe(''); + expect(result.country).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + expect(result.success).toBe(false); + }); + + it('should validate a missing tax id', async () => { + const result = useIndirectTaxForm({ programId: '' } as any); + const event = createEvent(); + + await result.onSubmit(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('Tax ID is required'); + }); + + it('should dispatch the tax form submit event', async () => { + universalHooksMock.values.push('VAT-123', 'CA'); + const dispatchSpy = document.dispatchEvent as ReturnType; + const result = useIndirectTaxForm({ programId: 'fallback-program' } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[3]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith(''); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:tax-form-submit'); + expect(event.detail).toEqual({ taxId: 'VAT-123', country: 'CA', programId: 'test-program' }); + expect(universalHooksMock.setters[4]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[3]).toHaveBeenNthCalledWith(2, false); + }); +}); diff --git a/packages/lit-components/src/components/IndirectTaxForm/useIndirectTaxForm.ts b/packages/lit-components/src/components/IndirectTaxForm/useIndirectTaxForm.ts new file mode 100644 index 0000000000..0422a5352b --- /dev/null +++ b/packages/lit-components/src/components/IndirectTaxForm/useIndirectTaxForm.ts @@ -0,0 +1,50 @@ +import { useProgramId } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { IndirectTaxFormProps } from './IndirectTaxForm'; + +export function useIndirectTaxForm(props: IndirectTaxFormProps) { + const programId = useProgramId() || props.programId; + const [taxId, setTaxId] = useState(''); + const [country, setCountry] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + async function onSubmit(e: Event) { + e.preventDefault(); + if (!taxId) { + setError('Tax ID is required'); + return; + } + setLoading(true); + setError(''); + const event = new CustomEvent('sq:tax-form-submit', { + bubbles: true, + composed: true, + detail: { taxId, country, programId }, + }); + document.dispatchEvent(event); + setSuccess(true); + setLoading(false); + } + + return { taxId, setTaxId, country, setCountry, error, loading, success, onSubmit }; +} + +export function useDemoIndirectTaxForm( + _props: IndirectTaxFormProps +): ReturnType { + const [taxId, setTaxId] = useState('VAT-123456789'); + const [country, setCountry] = useState('United States'); + const [error, setError] = useState(''); + const [loading] = useState(false); + const [success, setSuccess] = useState(false); + + async function onSubmit(event: Event) { + event.preventDefault(); + setError(''); + setSuccess(true); + } + + return { taxId, setTaxId, country, setCountry, error, loading, success, onSubmit }; +} diff --git a/packages/lit-components/src/components/InputField/InputField.stories.ts b/packages/lit-components/src/components/InputField/InputField.stories.ts new file mode 100644 index 0000000000..e27fe786dc --- /dev/null +++ b/packages/lit-components/src/components/InputField/InputField.stories.ts @@ -0,0 +1,124 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/InputField', + component: 'sql-input-field', + tags: ['autodocs'], + argTypes: { + 'field-label': { control: 'text' }, + 'field-name': { control: 'text' }, + 'field-required': { control: 'boolean' }, + 'field-disabled': { control: 'boolean' }, + 'field-placeholder': { control: 'text' }, + 'field-type': { control: 'select', options: ['text', 'number', 'email', 'tel', 'url', 'date'] }, + 'field-min-length': { control: 'number' }, + 'field-max-length': { control: 'number' }, + 'field-pattern': { control: 'text' }, + 'field-pattern-message': { control: 'text' }, + 'field-value': { control: 'text' }, + 'field-help-text': { control: 'text' }, + 'field-size': { control: 'select', options: ['small', 'medium', 'large'] }, + }, + render: (args) => html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + 'field-name': 'fullName', + }, +}; + +export const Required: Story = { + args: { + 'field-name': 'email', + 'field-label': 'Email', + 'field-required': true, + }, +}; + +export const WithPlaceholder: Story = { + args: { + 'field-name': 'company', + 'field-label': 'Company', + 'field-placeholder': 'Enter your company name', + }, +}; + +export const WithHelpText: Story = { + args: { + 'field-name': 'website', + 'field-label': 'Website', + 'field-help-text': 'Include the full URL, including https://', + 'field-type': 'url', + }, +}; + +export const Disabled: Story = { + args: { + 'field-name': 'disabledField', + 'field-label': 'Disabled field', + 'field-disabled': true, + 'field-value': 'Read only value', + }, +}; + +export const EmailType: Story = { + args: { + 'field-name': 'emailAddress', + 'field-label': 'Email Address', + 'field-type': 'email', + 'field-placeholder': 'name@example.com', + }, +}; + +export const NumberType: Story = { + args: { + 'field-name': 'quantity', + 'field-label': 'Quantity', + 'field-type': 'number', + 'field-value': '5', + }, +}; + +export const WithValidation: Story = { + args: { + 'field-name': 'username', + 'field-label': 'Username', + 'field-required': true, + 'field-min-length': 4, + 'field-max-length': 12, + 'field-pattern': '^[a-zA-Z0-9_]+$', + 'field-pattern-message': 'Only letters, numbers, and underscores are allowed', + 'field-help-text': '4-12 characters', + }, +}; + +export const WithError: Story = { + args: { + 'field-name': 'postalCode', + 'field-label': 'Postal Code', + 'field-required': true, + 'field-pattern': '^[0-9]{5}$', + 'field-pattern-message': 'Enter a valid 5-digit postal code', + 'field-value': '12', + }, +}; diff --git a/packages/lit-components/src/components/InputField/InputField.ts b/packages/lit-components/src/components/InputField/InputField.ts new file mode 100644 index 0000000000..f2fa4fa1eb --- /dev/null +++ b/packages/lit-components/src/components/InputField/InputField.ts @@ -0,0 +1,82 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { InputFieldView } from './InputFieldView'; +import { useInputField, useDemoInputField } from './useInputField'; + +export interface InputFieldProps { + fieldLabel: string; + fieldName: string; + fieldRequired: boolean; + fieldDisabled: boolean; + fieldPlaceholder?: string; + fieldType: 'text' | 'number' | 'email' | 'tel' | 'url' | 'date'; + fieldMinLength?: number; + fieldMaxLength?: number; + fieldPattern?: string; + fieldPatternMessage?: string; + fieldValue?: string; + fieldHelpText?: string; + fieldSize: 'small' | 'medium' | 'large'; +} + +function getBooleanAttribute(host: HTMLElement, name: string, defaultValue = false) { + if (!host.hasAttribute(name)) { + return defaultValue; + } + + const value = host.getAttribute(name); + return value === '' || value === 'true'; +} + +function getNumberAttribute(host: HTMLElement, name: string) { + const value = host.getAttribute(name); + return value !== null && value !== '' ? Number(value) : undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-input-field': HTMLElement; + } +} + +export const InputField = useComponent( + (host) => { + const rawProps = getProps(host); + const props: InputFieldProps = { + fieldLabel: rawProps.fieldLabel || 'Input', + fieldName: rawProps.fieldName || '', + fieldRequired: getBooleanAttribute(host, 'field-required', false), + fieldDisabled: getBooleanAttribute(host, 'field-disabled', false), + fieldPlaceholder: rawProps.fieldPlaceholder || undefined, + fieldType: (rawProps.fieldType as InputFieldProps['fieldType']) || 'text', + fieldMinLength: getNumberAttribute(host, 'field-min-length'), + fieldMaxLength: getNumberAttribute(host, 'field-max-length'), + fieldPattern: rawProps.fieldPattern || undefined, + fieldPatternMessage: rawProps.fieldPatternMessage || undefined, + fieldValue: rawProps.fieldValue || undefined, + fieldHelpText: rawProps.fieldHelpText || undefined, + fieldSize: (rawProps.fieldSize as InputFieldProps['fieldSize']) || 'medium', + }; + + const hookProps = isDemo() ? useDemoInputField(props) : useInputField(props); + + return InputFieldView({ ...props, ...hookProps }); + }, + 'sql-input-field', + [ + 'field-label', + 'field-name', + 'field-required', + 'field-disabled', + 'field-placeholder', + 'field-type', + 'field-min-length', + 'field-max-length', + 'field-pattern', + 'field-pattern-message', + 'field-value', + 'field-help-text', + 'field-size', + ] as const +); diff --git a/packages/lit-components/src/components/InputField/InputFieldView.ts b/packages/lit-components/src/components/InputField/InputFieldView.ts new file mode 100644 index 0000000000..60aef890e4 --- /dev/null +++ b/packages/lit-components/src/components/InputField/InputFieldView.ts @@ -0,0 +1,39 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { InputFieldProps } from './InputField'; +import { useInputField } from './useInputField'; + +const styles = ` + :host { + display: block; + } + + sl-input { + width: 100%; + } +`; + +export function InputFieldView(props: InputFieldProps & ReturnType) { + return html` + + ${UI.Input({ + label: props.fieldLabel, + name: props.fieldName, + type: props.fieldType, + placeholder: props.fieldPlaceholder || '', + required: props.fieldRequired, + disabled: props.fieldDisabled, + size: props.fieldSize, + minLength: props.fieldMinLength, + maxLength: props.fieldMaxLength, + pattern: props.fieldPattern || '', + value: props.value, + onInput: props.onInput, + onBlur: props.onBlur, + error: props.error, + helpText: props.fieldHelpText, + })} + `; +} diff --git a/packages/lit-components/src/components/InputField/index.ts b/packages/lit-components/src/components/InputField/index.ts new file mode 100644 index 0000000000..9cd7022484 --- /dev/null +++ b/packages/lit-components/src/components/InputField/index.ts @@ -0,0 +1 @@ +export * from './InputField'; diff --git a/packages/lit-components/src/components/InputField/useInputField.test.ts b/packages/lit-components/src/components/InputField/useInputField.test.ts new file mode 100644 index 0000000000..ccc1e8af91 --- /dev/null +++ b/packages/lit-components/src/components/InputField/useInputField.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useInputField } from './useInputField'; + +describe('useInputField', () => { + const defaultProps = { + fieldLabel: 'Label', + fieldName: 'field', + fieldRequired: false, + fieldDisabled: false, + fieldType: 'text', + fieldSize: 'medium', + fieldValue: '', + fieldMinLength: undefined, + fieldMaxLength: undefined, + fieldPattern: undefined, + fieldPatternMessage: undefined, + } as const; + + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + }); + + it('should return initial empty value', () => { + const result = useInputField(defaultProps); + + expect(result.value).toBe(''); + expect(result.error).toBe(''); + }); + + it('should handle required validation', () => { + const result = useInputField({ ...defaultProps, fieldRequired: true }); + + result.onBlur(); + + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('This field is required'); + }); + + it('should handle minLength validation', () => { + const result = useInputField({ ...defaultProps, fieldValue: 'abc', fieldMinLength: 5 }); + + result.onBlur(); + + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('Must be at least 5 characters'); + }); + + it('should handle pattern validation', () => { + const result = useInputField({ + ...defaultProps, + fieldValue: 'ABC', + fieldPattern: '^[a-z]+$', + fieldPatternMessage: 'Only lowercase letters', + }); + + result.onBlur(); + + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('Only lowercase letters'); + }); +}); diff --git a/packages/lit-components/src/components/InputField/useInputField.ts b/packages/lit-components/src/components/InputField/useInputField.ts new file mode 100644 index 0000000000..9df8fb4f97 --- /dev/null +++ b/packages/lit-components/src/components/InputField/useInputField.ts @@ -0,0 +1,101 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { InputFieldProps } from './InputField'; + +function getDemoValue(props: InputFieldProps) { + if (props.fieldValue) return props.fieldValue; + + switch (props.fieldType) { + case 'email': + return 'jane@example.com'; + case 'tel': + return '+1 555 123 4567'; + case 'url': + return 'https://example.com'; + case 'date': + return '2025-01-01'; + case 'number': + return '42'; + default: + return 'Jane Doe'; + } +} + +export function useInputField(props: InputFieldProps) { + const [value, setValue] = useState(props.fieldValue || ''); + const [touched, setTouched] = useState(false); + const [error, setError] = useState(''); + + function validate(val: string) { + if (props.fieldRequired && !val) { + return 'This field is required'; + } + if (props.fieldMinLength && val.length < props.fieldMinLength) { + return `Must be at least ${props.fieldMinLength} characters`; + } + if (props.fieldMaxLength && val.length > props.fieldMaxLength) { + return `Must be no more than ${props.fieldMaxLength} characters`; + } + if (props.fieldPattern) { + const regex = new RegExp(props.fieldPattern); + if (!regex.test(val)) { + return props.fieldPatternMessage || 'Invalid format'; + } + } + return ''; + } + + function onInput(e: Event) { + const target = e.target as HTMLInputElement; + setValue(target.value); + if (touched) { + setError(validate(target.value)); + } + } + + function onBlur() { + setTouched(true); + setError(validate(value)); + } + + return { value, error, touched, onInput, onBlur }; +} + +export function useDemoInputField(props: InputFieldProps): ReturnType { + const [value, setValue] = useState(getDemoValue(props)); + const [touched, setTouched] = useState(false); + const [error, setError] = useState(''); + + function validate(val: string) { + if (props.fieldRequired && !val) { + return 'This field is required'; + } + if (props.fieldMinLength && val.length < props.fieldMinLength) { + return `Must be at least ${props.fieldMinLength} characters`; + } + if (props.fieldMaxLength && val.length > props.fieldMaxLength) { + return `Must be no more than ${props.fieldMaxLength} characters`; + } + if (props.fieldPattern) { + const regex = new RegExp(props.fieldPattern); + if (!regex.test(val)) { + return props.fieldPatternMessage || 'Invalid format'; + } + } + return ''; + } + + function onInput(e: Event) { + const target = e.target as HTMLInputElement; + setValue(target.value); + if (touched) { + setError(validate(target.value)); + } + } + + function onBlur() { + setTouched(true); + setError(validate(value)); + } + + return { value, error, touched, onInput, onBlur }; +} diff --git a/packages/lit-components/src/components/InstantAccessRegistration/InstantAccessRegistration.stories.ts b/packages/lit-components/src/components/InstantAccessRegistration/InstantAccessRegistration.stories.ts new file mode 100644 index 0000000000..972fbc6831 --- /dev/null +++ b/packages/lit-components/src/components/InstantAccessRegistration/InstantAccessRegistration.stories.ts @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { InstantAccessRegistrationView } from './InstantAccessRegistrationView'; + +const meta: Meta = { + title: 'Components/InstantAccessRegistration', + component: 'sql-instant-access-registration', + tags: ['autodocs'], + argTypes: { + 'header-text': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'email-label': { control: 'text' }, + 'email-placeholder': { control: 'text' }, + 'success-message': { control: 'text' }, + 'program-id': { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Get Instant Access', + submitLabel: 'Get Started', + emailLabel: 'Email', + emailPlaceholder: 'Enter your email', + successMessage: 'Welcome! Check your email for next steps.', + programId: 'demo-program', +}; + +export const Default: Story = {}; + +export const WithError: Story = { + render: () => + InstantAccessRegistrationView({ + ...baseProps, + email: 'invalid-email', + setEmail: () => {}, + error: 'Please enter a valid email', + loading: false, + success: false, + onSubmit: (e: Event) => e.preventDefault(), + }), +}; + +export const Success: Story = { + render: () => + InstantAccessRegistrationView({ + ...baseProps, + email: 'demo@example.com', + setEmail: () => {}, + error: '', + loading: false, + success: true, + onSubmit: async (e: Event) => e.preventDefault(), + }), +}; + +export const Loading: Story = { + render: () => + InstantAccessRegistrationView({ + ...baseProps, + email: 'demo@example.com', + setEmail: () => {}, + error: '', + loading: true, + success: false, + onSubmit: async (e: Event) => e.preventDefault(), + }), +}; diff --git a/packages/lit-components/src/components/InstantAccessRegistration/InstantAccessRegistration.ts b/packages/lit-components/src/components/InstantAccessRegistration/InstantAccessRegistration.ts new file mode 100644 index 0000000000..e84a69e8fd --- /dev/null +++ b/packages/lit-components/src/components/InstantAccessRegistration/InstantAccessRegistration.ts @@ -0,0 +1,47 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { InstantAccessRegistrationView } from './InstantAccessRegistrationView'; +import { useDemoInstantAccessRegistration, useInstantAccessRegistration } from './useInstantAccessRegistration'; + +export interface InstantAccessRegistrationProps { + headerText: string; + submitLabel: string; + emailLabel: string; + emailPlaceholder: string; + successMessage: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-instant-access-registration': HTMLElement; + } +} + +export const InstantAccessRegistration = useComponent( + (host) => { + const rawProps = getProps(host); + const props: InstantAccessRegistrationProps = { + headerText: rawProps.headerText || 'Get Instant Access', + submitLabel: rawProps.submitLabel || 'Get Started', + emailLabel: rawProps.emailLabel || 'Email', + emailPlaceholder: rawProps.emailPlaceholder || 'Enter your email', + successMessage: rawProps.successMessage || 'Welcome! Check your email for next steps.', + programId: rawProps.programId || undefined, + }; + + const hookProps = isDemo() ? useDemoInstantAccessRegistration(props) : useInstantAccessRegistration(props); + + return InstantAccessRegistrationView({ ...props, ...hookProps }); + }, + 'sql-instant-access-registration', + [ + 'header-text', + 'submit-label', + 'email-label', + 'email-placeholder', + 'success-message', + 'program-id', + ] as const +); diff --git a/packages/lit-components/src/components/InstantAccessRegistration/InstantAccessRegistrationView.ts b/packages/lit-components/src/components/InstantAccessRegistration/InstantAccessRegistrationView.ts new file mode 100644 index 0000000000..9d7481bade --- /dev/null +++ b/packages/lit-components/src/components/InstantAccessRegistration/InstantAccessRegistrationView.ts @@ -0,0 +1,57 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { InstantAccessRegistrationProps } from './InstantAccessRegistration'; +import { useInstantAccessRegistration } from './useInstantAccessRegistration'; + +export function InstantAccessRegistrationView( + props: InstantAccessRegistrationProps & ReturnType +) { + return html` + +
+ ${props.success + ? html` + ${UI.Icon({ + name: 'check-circle', + style: 'font-size: 3rem; color: var(--sl-color-success-600);', + })} +

${props.successMessage}

+ ` + : html` +

${props.headerText}

+ ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} +
+ ${UI.Input({ + type: 'email', + label: props.emailLabel, + placeholder: props.emailPlaceholder, + value: props.email, + onInput: (e: Event) => props.setEmail((e.target as HTMLInputElement).value), + required: true, + })} + + ${UI.Button({ + type: 'submit', + variant: 'primary', + loading: props.loading, + style: 'width: 100%; margin-top: var(--sl-spacing-small);', + children: props.submitLabel, + })} +
+ `} +
+ `; +} diff --git a/packages/lit-components/src/components/InstantAccessRegistration/index.ts b/packages/lit-components/src/components/InstantAccessRegistration/index.ts new file mode 100644 index 0000000000..023848e0b9 --- /dev/null +++ b/packages/lit-components/src/components/InstantAccessRegistration/index.ts @@ -0,0 +1 @@ +export * from './InstantAccessRegistration'; diff --git a/packages/lit-components/src/components/InstantAccessRegistration/useInstantAccessRegistration.test.ts b/packages/lit-components/src/components/InstantAccessRegistration/useInstantAccessRegistration.test.ts new file mode 100644 index 0000000000..83f503b33f --- /dev/null +++ b/packages/lit-components/src/components/InstantAccessRegistration/useInstantAccessRegistration.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), +})); + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useInstantAccessRegistration } from './useInstantAccessRegistration'; + +describe('useInstantAccessRegistration', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + componentBoilerplateMock.useProgramId.mockReturnValue('test-program'); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = useInstantAccessRegistration({ programId: '' } as any); + + expect(result.email).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + expect(result.success).toBe(false); + }); + + it('should validate an empty email', async () => { + const result = useInstantAccessRegistration({ programId: '' } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith('Email is required'); + }); + + it('should validate an invalid email', async () => { + universalHooksMock.values.push('invalid-email'); + const result = useInstantAccessRegistration({ programId: '' } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith('Please enter a valid email'); + }); + + it('should dispatch the instant access event for a valid email', async () => { + universalHooksMock.values.push('user@example.com'); + const dispatchSpy = document.dispatchEvent as ReturnType; + const result = useInstantAccessRegistration({ programId: 'fallback-program' } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith(''); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:instant-access'); + expect(event.detail).toEqual({ email: 'user@example.com', programId: 'test-program' }); + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(2, false); + }); +}); diff --git a/packages/lit-components/src/components/InstantAccessRegistration/useInstantAccessRegistration.ts b/packages/lit-components/src/components/InstantAccessRegistration/useInstantAccessRegistration.ts new file mode 100644 index 0000000000..ecaa916366 --- /dev/null +++ b/packages/lit-components/src/components/InstantAccessRegistration/useInstantAccessRegistration.ts @@ -0,0 +1,52 @@ +import { useProgramId } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import type { InstantAccessRegistrationProps } from './InstantAccessRegistration'; + +export function useInstantAccessRegistration(props: InstantAccessRegistrationProps) { + const programId = useProgramId() || props.programId; + const [email, setEmail] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + async function onSubmit(e: Event) { + e.preventDefault(); + if (!email) { + setError('Email is required'); + return; + } + if (!email.includes('@')) { + setError('Please enter a valid email'); + return; + } + setLoading(true); + setError(''); + const event = new CustomEvent('sq:instant-access', { + bubbles: true, + composed: true, + detail: { email, programId }, + }); + document.dispatchEvent(event); + setSuccess(true); + setLoading(false); + } + + return { email, setEmail, error, loading, success, onSubmit }; +} + +export function useDemoInstantAccessRegistration( + _props: InstantAccessRegistrationProps +): ReturnType { + const [email, setEmail] = useState('jane@example.com'); + const [error, setError] = useState(''); + const [loading] = useState(false); + const [success, setSuccess] = useState(false); + + async function onSubmit(event: Event) { + event.preventDefault(); + setError(''); + setSuccess(true); + } + + return { email, setEmail, error, loading, success, onSubmit }; +} diff --git a/packages/lit-components/src/components/InvoiceTable/InvoiceTable.stories.ts b/packages/lit-components/src/components/InvoiceTable/InvoiceTable.stories.ts new file mode 100644 index 0000000000..47b5d1ac34 --- /dev/null +++ b/packages/lit-components/src/components/InvoiceTable/InvoiceTable.stories.ts @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { InvoiceTableProps } from './InvoiceTable'; +import { InvoiceTableView } from './InvoiceTableView'; + +const sampleInvoices = [ + { + id: 'invoice-1', + dateCreated: '2024-01-15T00:00:00.000Z', + invoiceNumber: 'INV-1001', + downloadUrl: 'https://example.com/invoice-1001.pdf', + amount: 125, + currency: 'USD', + }, + { + id: 'invoice-2', + dateCreated: '2024-02-01T00:00:00.000Z', + invoiceNumber: 'INV-1002', + downloadUrl: 'https://example.com/invoice-1002.pdf', + amount: 80, + currency: 'USD', + }, + { + id: 'invoice-3', + dateCreated: '2024-02-20T00:00:00.000Z', + invoiceNumber: 'INV-1003', + downloadUrl: 'https://example.com/invoice-1003.pdf', + amount: 64, + currency: 'USD', + }, + { + id: 'invoice-4', + dateCreated: '2024-03-01T00:00:00.000Z', + invoiceNumber: 'INV-1004', + downloadUrl: 'https://example.com/invoice-1004.pdf', + amount: 42, + currency: 'USD', + }, +]; + +const baseProps: InvoiceTableProps = { + perPage: 4, + programId: '41863', + dateShownColumn: 'Date', + invoiceShownColumn: 'Invoice', + downloadShownColumn: 'Download', + emptyStateText: 'No invoices yet', +}; + +const renderTable = ( + args: Partial = {}, + overrides: Partial[0]> = {} +) => + InvoiceTableView({ + ...baseProps, + ...args, + invoices: sampleInvoices, + loading: false, + totalCount: sampleInvoices.length, + totalPages: 1, + currentPage: 0, + nextPage: () => undefined, + prevPage: () => undefined, + empty: false, + ...overrides, + }); + +const meta: Meta = { + title: 'Components/InvoiceTable', + component: 'sql-invoice-table', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => renderTable(), +}; + +export const Loading: Story = { + render: () => renderTable({}, { loading: true }), +}; + +export const Empty: Story = { + render: () => renderTable({}, { invoices: [], totalCount: 0, totalPages: 0, empty: true }), +}; + +export const WithPagination: Story = { + render: () => renderTable({}, { totalCount: 8, totalPages: 2, currentPage: 0 }), +}; diff --git a/packages/lit-components/src/components/InvoiceTable/InvoiceTable.ts b/packages/lit-components/src/components/InvoiceTable/InvoiceTable.ts new file mode 100644 index 0000000000..61c8e39798 --- /dev/null +++ b/packages/lit-components/src/components/InvoiceTable/InvoiceTable.ts @@ -0,0 +1,58 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { InvoiceTableView } from './InvoiceTableView'; +import { useDemoInvoiceTable, useInvoiceTable } from './useInvoiceTable'; + +export interface InvoiceTableProps { + perPage: number; + programId?: string; + dateShownColumn: string; + invoiceShownColumn: string; + downloadShownColumn: string; + emptyStateText: string; +} + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsedValue = Number(value); + return Number.isNaN(parsedValue) ? fallback : parsedValue; + } + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-invoice-table': HTMLElement; + } +} + +export const InvoiceTable = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: InvoiceTableProps = { + perPage: parseNumber(rawProps.perPage, 4), + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + dateShownColumn: typeof rawProps.dateShownColumn === 'string' ? rawProps.dateShownColumn : 'Date', + invoiceShownColumn: + typeof rawProps.invoiceShownColumn === 'string' ? rawProps.invoiceShownColumn : 'Invoice', + downloadShownColumn: + typeof rawProps.downloadShownColumn === 'string' ? rawProps.downloadShownColumn : 'Download', + emptyStateText: typeof rawProps.emptyStateText === 'string' ? rawProps.emptyStateText : 'No invoices yet', + }; + + const hookProps = isDemo() ? useDemoInvoiceTable(props) : useInvoiceTable(props); + + return InvoiceTableView({ ...props, ...hookProps }); + }, + 'sql-invoice-table', + [ + 'per-page', + 'program-id', + 'date-shown-column', + 'invoice-shown-column', + 'download-shown-column', + 'empty-state-text', + ] as const +); diff --git a/packages/lit-components/src/components/InvoiceTable/InvoiceTableView.ts b/packages/lit-components/src/components/InvoiceTable/InvoiceTableView.ts new file mode 100644 index 0000000000..39bb495377 --- /dev/null +++ b/packages/lit-components/src/components/InvoiceTable/InvoiceTableView.ts @@ -0,0 +1,112 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { InvoiceTableProps } from './InvoiceTable'; +import { useInvoiceTable } from './useInvoiceTable'; + +type InvoiceRow = ReturnType['invoices'][number]; + +const baseStyles = ` + :host { display: block; } + .table-container { width: 100%; overflow-x: auto; } + table { width: 100%; border-collapse: collapse; } + th { text-align: left; padding: var(--sl-spacing-small) var(--sl-spacing-medium); font-weight: var(--sl-font-weight-semibold); font-size: var(--sl-font-size-small); color: var(--sl-color-neutral-600); border-bottom: 2px solid var(--sl-color-neutral-200); } + td { padding: var(--sl-spacing-small) var(--sl-spacing-medium); border-bottom: 1px solid var(--sl-color-neutral-100); font-size: var(--sl-font-size-small); } + tr:hover { background: var(--sl-color-neutral-50); } + .pagination { display: flex; justify-content: center; align-items: center; gap: var(--sl-spacing-small); padding: var(--sl-spacing-medium) 0; } + .empty-state { text-align: center; padding: var(--sl-spacing-x-large); color: var(--sl-color-neutral-500); } + .page-info { font-size: var(--sl-font-size-small); color: var(--sl-color-neutral-600); } + .download-link { color: var(--sl-color-primary-600); text-decoration: none; } + .download-link:hover { text-decoration: underline; } +`; + +function formatDate(date?: string) { + if (!date) return '—'; + return new Date(date).toLocaleDateString(); +} + +function getInvoiceSummary(invoice: { + invoiceNumber?: string; + amount?: string | number; + currency?: string; +}) { + const invoiceNumber = invoice.invoiceNumber || '—'; + if (invoice.amount === undefined || invoice.amount === null) return invoiceNumber; + return `${invoiceNumber} (${invoice.amount} ${invoice.currency || ''})`.trim(); +} + +export function InvoiceTableView(props: InvoiceTableProps & ReturnType) { + if (props.loading) { + return html` + +
Loading invoices...
+ `; + } + + if (props.empty) { + return html` + +
${props.emptyStateText || 'No invoices yet'}
+ `; + } + + return html` + +
+ + + + + + + + + + ${props.invoices.map( + (invoice: InvoiceRow) => html` + + + + + + ` + )} + +
${props.dateShownColumn || 'Date'}${props.invoiceShownColumn || 'Invoice'}${props.downloadShownColumn || 'Download'}
${formatDate(invoice.dateCreated)}${getInvoiceSummary(invoice)} + ${invoice.downloadUrl + ? html` + + Download + + ` + : '—'} +
+
+ ${props.totalPages > 1 + ? html` + + ` + : ''} + `; +} diff --git a/packages/lit-components/src/components/InvoiceTable/index.ts b/packages/lit-components/src/components/InvoiceTable/index.ts new file mode 100644 index 0000000000..c7bf186be1 --- /dev/null +++ b/packages/lit-components/src/components/InvoiceTable/index.ts @@ -0,0 +1 @@ +export * from './InvoiceTable'; diff --git a/packages/lit-components/src/components/InvoiceTable/useInvoiceTable.test.ts b/packages/lit-components/src/components/InvoiceTable/useInvoiceTable.test.ts new file mode 100644 index 0000000000..797e3f0e6f --- /dev/null +++ b/packages/lit-components/src/components/InvoiceTable/useInvoiceTable.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { user: { payoutInvoices: { data: [{ id: 'inv-1' }], totalCount: 9 } } }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useInvoiceTable } from './useInvoiceTable'; + +describe('useInvoiceTable', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + componentBoilerplateMock.useProgramId.mockReturnValue('test-program'); + componentBoilerplateMock.useUserIdentity.mockReturnValue({ jwt: 'test-jwt' }); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { payoutInvoices: { data: [{ id: 'inv-1' }, { id: 'inv-2' }], totalCount: 9 } } }, + loading: false, + }); + }); + + it('should return invoices from the query and calculate pagination', () => { + const result = useInvoiceTable({ programId: 'fallback-program' } as any); + + expect(componentBoilerplateMock.useQuery).toHaveBeenCalledWith( + expect.anything(), + { programId: 'test-program', offset: 0, limit: 4 }, + false + ); + expect(result.invoices).toEqual([{ id: 'inv-1' }, { id: 'inv-2' }]); + expect(result.totalCount).toBe(9); + expect(result.totalPages).toBe(3); + expect(result.currentPage).toBe(0); + expect(result.empty).toBe(false); + }); + + it('should expose empty when there are no invoices', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { payoutInvoices: { data: [], totalCount: 0 } } }, + loading: false, + }); + + const result = useInvoiceTable({ programId: '' } as any); + + expect(result.invoices).toEqual([]); + expect(result.empty).toBe(true); + }); + + it('should advance and go back between pages', () => { + let result = useInvoiceTable({ programId: '' } as any); + result.nextPage(); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(1); + + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + universalHooksMock.values.push(1); + result = useInvoiceTable({ programId: '' } as any); + result.prevPage(); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/lit-components/src/components/InvoiceTable/useInvoiceTable.ts b/packages/lit-components/src/components/InvoiceTable/useInvoiceTable.ts new file mode 100644 index 0000000000..d60166103a --- /dev/null +++ b/packages/lit-components/src/components/InvoiceTable/useInvoiceTable.ts @@ -0,0 +1,93 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { InvoiceTableProps } from './InvoiceTable'; + +const INVOICE_TABLE_QUERY = gql` + query getInvoices($programId: ID, $offset: Int, $limit: Int) { + user: viewer { + ... on User { + payoutInvoices(filter: { programId_eq: $programId }, limit: $limit, offset: $offset) { + totalCount + count + data { + id + dateCreated + invoiceNumber + downloadUrl + amount + currency + } + } + } + } + } +`; + +export function useInvoiceTable(props: InvoiceTableProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const perPage = Number(props.perPage) || 4; + const [currentPage, setCurrentPage] = useState(0); + + const { data, loading } = useQuery( + INVOICE_TABLE_QUERY, + { programId, offset: currentPage * perPage, limit: perPage }, + !user?.jwt + ); + + const invoices = data?.user?.payoutInvoices?.data || []; + const totalCount = data?.user?.payoutInvoices?.totalCount || 0; + const totalPages = Math.ceil(totalCount / perPage); + + function nextPage() { + if (currentPage < totalPages - 1) setCurrentPage(currentPage + 1); + } + + function prevPage() { + if (currentPage > 0) setCurrentPage(currentPage - 1); + } + + return { + invoices, + loading, + totalCount, + totalPages, + currentPage, + nextPage, + prevPage, + empty: !loading && invoices.length === 0, + }; +} + +export function useDemoInvoiceTable(props: InvoiceTableProps): ReturnType { + const perPage = Number(props.perPage) || 4; + const [currentPage, setCurrentPage] = useState(0); + const mockInvoices = [ + { id: '1', dateCreated: '2024-01-15', invoiceNumber: 'INV-001', downloadUrl: '#', amount: 100, currency: 'USD' }, + { id: '2', dateCreated: '2024-02-10', invoiceNumber: 'INV-002', downloadUrl: '#', amount: 250, currency: 'USD' }, + { id: '3', dateCreated: '2024-03-05', invoiceNumber: 'INV-003', downloadUrl: '#', amount: 75, currency: 'USD' }, + ]; + const invoices = mockInvoices.slice(currentPage * perPage, (currentPage + 1) * perPage); + const totalCount = mockInvoices.length; + const totalPages = Math.ceil(totalCount / perPage); + + function nextPage() { + if (currentPage < totalPages - 1) setCurrentPage(currentPage + 1); + } + + function prevPage() { + if (currentPage > 0) setCurrentPage(currentPage - 1); + } + + return { + invoices, + loading: false, + totalCount, + totalPages, + currentPage, + nextPage, + prevPage, + empty: invoices.length === 0, + }; +} diff --git a/packages/lit-components/src/components/LeadForm/LeadForm.stories.ts b/packages/lit-components/src/components/LeadForm/LeadForm.stories.ts new file mode 100644 index 0000000000..8dd39f907f --- /dev/null +++ b/packages/lit-components/src/components/LeadForm/LeadForm.stories.ts @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { UI } from '../../ui'; +import '../../index'; +import { LeadFormView } from './LeadFormView'; + +const meta: Meta = { + title: 'Components/LeadForm', + component: 'sql-lead-form', + tags: ['autodocs'], + argTypes: { + 'header-text': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'success-message': { control: 'text' }, + 'program-id': { control: 'text' }, + }, + render: (args) => html` + + ${UI.Input({ label: 'First Name' })} + ${UI.Input({ label: 'Last Name' })} + ${UI.Input({ label: 'Email', type: 'email' })} + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Get Started', + submitLabel: 'Submit', + successMessage: 'Thank you for your submission!', + programId: 'demo-program', +}; + +export const Default: Story = {}; + +export const WithError: Story = { + render: () => + LeadFormView({ + ...baseProps, + formData: {}, + updateField: () => {}, + error: 'An error occurred. Please try again.', + loading: false, + success: false, + onSubmit: (e: Event) => e.preventDefault(), + }), +}; + +export const Success: Story = { + render: () => + LeadFormView({ + ...baseProps, + formData: {}, + updateField: () => {}, + error: '', + loading: false, + success: true, + onSubmit: (e: Event) => e.preventDefault(), + }), +}; + +export const Loading: Story = { + render: () => + LeadFormView({ + ...baseProps, + formData: {}, + updateField: () => {}, + error: '', + loading: true, + success: false, + onSubmit: (e: Event) => e.preventDefault(), + }), +}; diff --git a/packages/lit-components/src/components/LeadForm/LeadForm.ts b/packages/lit-components/src/components/LeadForm/LeadForm.ts new file mode 100644 index 0000000000..8d4e695006 --- /dev/null +++ b/packages/lit-components/src/components/LeadForm/LeadForm.ts @@ -0,0 +1,36 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { LeadFormView } from './LeadFormView'; +import { useDemoLeadForm, useLeadForm } from './useLeadForm'; + +export interface LeadFormProps { + headerText: string; + submitLabel: string; + successMessage: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-lead-form': HTMLElement; + } +} + +export const LeadForm = useComponent( + (host) => { + const rawProps = getProps(host); + const props: LeadFormProps = { + headerText: rawProps.headerText || 'Get Started', + submitLabel: rawProps.submitLabel || 'Submit', + successMessage: rawProps.successMessage || 'Thank you for your submission!', + programId: rawProps.programId || undefined, + }; + + const hookProps = isDemo() ? useDemoLeadForm(props) : useLeadForm(props); + + return LeadFormView({ ...props, ...hookProps }); + }, + 'sql-lead-form', + ['header-text', 'submit-label', 'success-message', 'program-id'] as const +); diff --git a/packages/lit-components/src/components/LeadForm/LeadFormView.ts b/packages/lit-components/src/components/LeadForm/LeadFormView.ts new file mode 100644 index 0000000000..277e13a778 --- /dev/null +++ b/packages/lit-components/src/components/LeadForm/LeadFormView.ts @@ -0,0 +1,39 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { LeadFormProps } from './LeadForm'; +import { useLeadForm } from './useLeadForm'; + +export function LeadFormView(props: LeadFormProps & ReturnType) { + return html` + +
+ ${props.success + ? html` + ${UI.Alert({ + variant: 'success', + open: true, + icon: UI.Icon({ name: 'check-circle' }), + children: props.successMessage, + })} + ` + : html` + ${props.headerText ? html`

${props.headerText}

` : ''} + ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} +
+ + ${UI.Button({ + type: 'submit', + variant: 'primary', + loading: props.loading, + style: 'width: 100%; margin-top: var(--sl-spacing-small);', + children: props.submitLabel, + })} +
+ `} +
+ `; +} diff --git a/packages/lit-components/src/components/LeadForm/index.ts b/packages/lit-components/src/components/LeadForm/index.ts new file mode 100644 index 0000000000..db81e25cfd --- /dev/null +++ b/packages/lit-components/src/components/LeadForm/index.ts @@ -0,0 +1 @@ +export * from './LeadForm'; diff --git a/packages/lit-components/src/components/LeadForm/useLeadForm.test.ts b/packages/lit-components/src/components/LeadForm/useLeadForm.test.ts new file mode 100644 index 0000000000..22d722d725 --- /dev/null +++ b/packages/lit-components/src/components/LeadForm/useLeadForm.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useMutation: vi.fn(() => [vi.fn().mockResolvedValue({ success: true })]), +})); + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useLeadForm } from './useLeadForm'; + +describe('useLeadForm', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + componentBoilerplateMock.useProgramId.mockReturnValue('test-program'); + componentBoilerplateMock.useMutation.mockReturnValue([vi.fn().mockResolvedValue({ success: true })]); + }); + + it('should return initial state', () => { + const result = useLeadForm({ programId: '' } as any); + + expect(result.formData).toEqual({}); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + expect(result.success).toBe(false); + }); + + it('should update a form field', () => { + const result = useLeadForm({ programId: '' } as any); + + result.updateField('firstName', 'Ada'); + + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith({ firstName: 'Ada' }); + }); + + it('should submit the form data', async () => { + universalHooksMock.values.push({ firstName: 'Ada', company: 'Example Co' }); + const submitForm = vi.fn().mockResolvedValue({ success: true }); + componentBoilerplateMock.useMutation.mockReturnValue([submitForm]); + const result = useLeadForm({ programId: 'fallback-program' } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith(''); + expect(submitForm).toHaveBeenCalledWith({ + formData: { firstName: 'Ada', company: 'Example Co' }, + programId: 'test-program', + }); + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(2, false); + }); + + it('should surface a submission error', async () => { + const submitForm = vi.fn().mockRejectedValue(new Error('boom')); + componentBoilerplateMock.useMutation.mockReturnValue([submitForm]); + const result = useLeadForm({ programId: '' } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith('An error occurred. Please try again.'); + }); +}); diff --git a/packages/lit-components/src/components/LeadForm/useLeadForm.ts b/packages/lit-components/src/components/LeadForm/useLeadForm.ts new file mode 100644 index 0000000000..d1cc458895 --- /dev/null +++ b/packages/lit-components/src/components/LeadForm/useLeadForm.ts @@ -0,0 +1,63 @@ +import { useMutation, useProgramId } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import type { LeadFormProps } from './LeadForm'; + +const LEAD_FORM_MUTATION = gql` + mutation submitLeadForm($formData: JSONObject!, $programId: ID) { + submitForm(formData: $formData, programId: $programId) { + success + } + } +`; + +export function useLeadForm(props: LeadFormProps) { + const programId = useProgramId() || props.programId; + const [formData, setFormData] = useState>({}); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [submitForm] = useMutation(LEAD_FORM_MUTATION); + + function updateField(name: string, value: string) { + setFormData({ ...formData, [name]: value }); + } + + async function onSubmit(e: Event) { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await submitForm({ formData, programId }); + setSuccess(true); + } catch (_err) { + setError('An error occurred. Please try again.'); + } + setLoading(false); + } + + return { formData, updateField, error, loading, success, onSubmit }; +} + +export function useDemoLeadForm(_props: LeadFormProps): ReturnType { + const [formData, setFormData] = useState>({ + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@example.com', + }); + const [error, setError] = useState(''); + const [loading] = useState(false); + const [success, setSuccess] = useState(false); + + function updateField(name: string, value: string) { + setFormData({ ...formData, [name]: value }); + } + + async function onSubmit(event: Event) { + event.preventDefault(); + setError(''); + setSuccess(true); + } + + return { formData, updateField, error, loading, success, onSubmit }; +} diff --git a/packages/lit-components/src/components/Leaderboard/Leaderboard.stories.ts b/packages/lit-components/src/components/Leaderboard/Leaderboard.stories.ts new file mode 100644 index 0000000000..bba854606c --- /dev/null +++ b/packages/lit-components/src/components/Leaderboard/Leaderboard.stories.ts @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import type { LeaderboardProps, LeaderboardRow } from './Leaderboard'; +import { LeaderboardView } from './LeaderboardView'; +import type { LeaderboardHookResult } from './useLeaderboard'; + +const mockRows: LeaderboardRow[] = [ + { rank: 1, firstName: 'Morgan', lastInitial: 'L', statValue: 42 }, + { rank: 2, firstName: 'Avery', lastInitial: 'K', statValue: 36 }, + { rank: 3, firstName: 'Jordan', lastInitial: 'P', statValue: 29 }, + { rank: 4, firstName: 'Casey', lastInitial: 'N', statValue: 21 }, + { rank: 5, firstName: 'Taylor', lastInitial: 'R', statValue: 18 }, +]; + +const defaultArgs: LeaderboardProps = { + leaderboardType: 'topStartedReferrers', + rankType: 'rowNumber', + showRank: true, + showUser: true, + showStats: true, + emptyStateText: 'No data available', + maxRows: 10, +}; + +function normalizeArgs(args: Partial): LeaderboardProps { + return { + ...defaultArgs, + ...args, + showRank: args.showRank ?? defaultArgs.showRank, + showUser: args.showUser ?? defaultArgs.showUser, + showStats: args.showStats ?? defaultArgs.showStats, + maxRows: Number(args.maxRows ?? defaultArgs.maxRows), + }; +} + +function renderView( + args: Partial, + hookProps: Partial = {} +) { + const props = normalizeArgs(args); + const rows = hookProps.rows ?? mockRows.slice(0, props.maxRows); + + return LeaderboardView({ + ...props, + rows, + loading: hookProps.loading ?? false, + empty: hookProps.empty ?? rows.length === 0, + }); +} + +const meta = { + title: 'Components/Leaderboard', + component: 'sql-leaderboard', + tags: ['autodocs'], + argTypes: { + leaderboardType: { control: 'text' }, + rankType: { control: 'text' }, + showRank: { control: 'boolean' }, + showUser: { control: 'boolean' }, + showStats: { control: 'boolean' }, + header: { control: 'text' }, + emptyStateText: { control: 'text' }, + maxRows: { control: 'number' }, + }, + render: (args: Partial) => renderView(args), +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + render: (args) => renderView(args, { rows: [], loading: true, empty: false }), +}; + +export const Empty: Story = { + render: (args) => renderView(args, { rows: [], loading: false, empty: true }), +}; + +export const WithHeader: Story = { + args: { + header: 'Top Referrers', + }, +}; + +export const HideRank: Story = { + args: { + showRank: false, + }, +}; + +export const MaxRows: Story = { + args: { + maxRows: 3, + }, + render: (args) => { + const props = normalizeArgs(args); + return renderView(props, { rows: mockRows.slice(0, props.maxRows) }); + }, +}; diff --git a/packages/lit-components/src/components/Leaderboard/Leaderboard.ts b/packages/lit-components/src/components/Leaderboard/Leaderboard.ts new file mode 100644 index 0000000000..ae42a4e0be --- /dev/null +++ b/packages/lit-components/src/components/Leaderboard/Leaderboard.ts @@ -0,0 +1,83 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { LeaderboardView } from './LeaderboardView'; +import { useDemoLeaderboard, useLeaderboard } from './useLeaderboard'; + +export interface LeaderboardRow { + rank?: number | null; + firstName?: string | null; + lastInitial?: string | null; + value?: number | string | null; + statValue?: number | string | null; +} + +export interface LeaderboardProps { + leaderboardType: string; + programId?: string; + rankType: string; + showRank: boolean; + showUser: boolean; + showStats: boolean; + header?: string; + emptyStateText: string; + maxRows: number; +} + +const parseBoolean = (value: unknown, fallback: boolean) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-leaderboard': HTMLElement; + } +} + +export const Leaderboard = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: LeaderboardProps = { + leaderboardType: + typeof rawProps.leaderboardType === 'string' + ? rawProps.leaderboardType + : 'topStartedReferrers', + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + rankType: typeof rawProps.rankType === 'string' ? rawProps.rankType : 'rowNumber', + showRank: parseBoolean(rawProps.showRank, true), + showUser: parseBoolean(rawProps.showUser, true), + showStats: parseBoolean(rawProps.showStats, true), + header: typeof rawProps.header === 'string' ? rawProps.header : undefined, + emptyStateText: + typeof rawProps.emptyStateText === 'string' ? rawProps.emptyStateText : 'No data available', + maxRows: parseNumber(rawProps.maxRows, 10), + }; + + const hookProps = isDemo() ? useDemoLeaderboard(props) : useLeaderboard(props); + + return LeaderboardView({ ...props, ...hookProps }); + }, + 'sql-leaderboard', + [ + 'leaderboard-type', + 'program-id', + 'rank-type', + 'show-rank', + 'show-user', + 'show-stats', + 'header', + 'empty-state-text', + 'max-rows', + ] as const +); diff --git a/packages/lit-components/src/components/Leaderboard/LeaderboardView.ts b/packages/lit-components/src/components/Leaderboard/LeaderboardView.ts new file mode 100644 index 0000000000..91b931591a --- /dev/null +++ b/packages/lit-components/src/components/Leaderboard/LeaderboardView.ts @@ -0,0 +1,59 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { LeaderboardProps, LeaderboardRow } from './Leaderboard'; +import { LeaderboardHookResult } from './useLeaderboard'; + +function formatName(row: LeaderboardRow) { + const firstName = row.firstName?.trim() || ''; + const lastInitial = row.lastInitial?.trim() || ''; + if (firstName && lastInitial) return `${firstName} ${lastInitial}.`; + if (firstName) return firstName; + if (lastInitial) return `${lastInitial}.`; + return 'Anonymous'; +} + +function formatValue(row: LeaderboardRow) { + return row.statValue ?? row.value ?? ''; +} + +export function LeaderboardView(props: LeaderboardProps & LeaderboardHookResult) { + return html` + +
+ ${props.header ? html`

${props.header}

` : null} + ${props.loading + ? html`${UI.Spinner({})}` + : props.empty + ? html`
${props.emptyStateText}
` + : html` + + + ${props.showRank ? html`` : null} + ${props.showUser ? html`` : null} + ${props.showStats ? html`` : null} + + + + ${props.rows.map( + (row, index) => html` + ${props.showRank ? html`` : null} + ${props.showUser ? html`` : null} + ${props.showStats ? html`` : null} + ` + )} + +
#NameScore
${row.rank || index + 1}${formatName(row)}${formatValue(row)}
`} +
+ `; +} diff --git a/packages/lit-components/src/components/Leaderboard/index.ts b/packages/lit-components/src/components/Leaderboard/index.ts new file mode 100644 index 0000000000..8adbc3e2fb --- /dev/null +++ b/packages/lit-components/src/components/Leaderboard/index.ts @@ -0,0 +1 @@ +export * from './Leaderboard'; diff --git a/packages/lit-components/src/components/Leaderboard/useLeaderboard.test.ts b/packages/lit-components/src/components/Leaderboard/useLeaderboard.test.ts new file mode 100644 index 0000000000..b723d3268c --- /dev/null +++ b/packages/lit-components/src/components/Leaderboard/useLeaderboard.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { + leaderboard: { + rows: [ + { rank: 1, firstName: 'Jane', lastInitial: 'D', value: 10, statValue: 10 }, + null, + { rank: 2, firstName: 'John', lastInitial: 'S', value: 8, statValue: 8 }, + ], + }, + }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useLeaderboard } from './useLeaderboard'; + +describe('useLeaderboard', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockClear(); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + leaderboard: { + rows: [ + { rank: 1, firstName: 'Jane', lastInitial: 'D', value: 10, statValue: 10 }, + null, + { rank: 2, firstName: 'John', lastInitial: 'S', value: 8, statValue: 8 }, + ], + }, + }, + loading: false, + }); + }); + + it('returns rows from the query data', () => { + const result = useLeaderboard({ leaderboardType: 'referrals' } as any); + + expect(result.rows).toHaveLength(2); + expect(result.rows[0]?.firstName).toBe('Jane'); + }); + + it('returns empty when there are no rows and not loading', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { leaderboard: { rows: [] } }, + loading: false, + }); + + const result = useLeaderboard({ leaderboardType: 'referrals' } as any); + + expect(result.empty).toBe(true); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { leaderboard: { rows: [] } }, + loading: true, + }); + + const result = useLeaderboard({ leaderboardType: 'referrals' } as any); + + expect(result.loading).toBe(true); + expect(result.empty).toBe(false); + }); + + it('filters null rows from the result', () => { + const result = useLeaderboard({ leaderboardType: 'referrals' } as any); + + expect(result.rows.some((row) => row == null)).toBe(false); + }); +}); diff --git a/packages/lit-components/src/components/Leaderboard/useLeaderboard.ts b/packages/lit-components/src/components/Leaderboard/useLeaderboard.ts new file mode 100644 index 0000000000..2dafebf949 --- /dev/null +++ b/packages/lit-components/src/components/Leaderboard/useLeaderboard.ts @@ -0,0 +1,65 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import type { LeaderboardProps, LeaderboardRow } from './Leaderboard'; + +interface LeaderboardQueryResult { + leaderboard?: { + rows?: Array | null; + } | null; +} + +export interface LeaderboardHookResult { + rows: LeaderboardRow[]; + loading: boolean; + empty: boolean; +} + +const LEADERBOARD_QUERY = gql` + query getLeaderboard($type: String!, $filter: LeaderboardFilterInput, $limit: Int) { + leaderboard(type: $type, filter: $filter, limit: $limit) { + rows { + rank + firstName + lastInitial + value + statValue + } + } + } +`; + +const DEMO_ROWS: LeaderboardRow[] = [ + { rank: 1, firstName: 'Morgan', lastInitial: 'L', statValue: 42 }, + { rank: 2, firstName: 'Avery', lastInitial: 'K', statValue: 36 }, + { rank: 3, firstName: 'Jordan', lastInitial: 'P', statValue: 29 }, + { rank: 4, firstName: 'Casey', lastInitial: 'N', statValue: 21 }, + { rank: 5, firstName: 'Taylor', lastInitial: 'R', statValue: 18 }, +]; + +export function useLeaderboard(props: LeaderboardProps): LeaderboardHookResult { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const limit = Number(props.maxRows) || 10; + + const { data, loading } = useQuery( + LEADERBOARD_QUERY, + { + type: props.leaderboardType, + filter: programId ? { programId_eq: programId } : {}, + limit, + }, + !user?.jwt + ); + + const rows = (data?.leaderboard?.rows || []).filter((row): row is LeaderboardRow => Boolean(row)); + return { rows, loading, empty: !loading && rows.length === 0 }; +} + +export function useDemoLeaderboard(props: LeaderboardProps): LeaderboardHookResult { + const [rows] = useState(DEMO_ROWS); + const limit = Number(props.maxRows) || 10; + const limitedRows = rows.slice(0, limit); + + return { rows: limitedRows, loading: false, empty: limitedRows.length === 0 }; +} diff --git a/packages/lit-components/src/components/LeaderboardRank/LeaderboardRank.stories.ts b/packages/lit-components/src/components/LeaderboardRank/LeaderboardRank.stories.ts new file mode 100644 index 0000000000..c21be0a716 --- /dev/null +++ b/packages/lit-components/src/components/LeaderboardRank/LeaderboardRank.stories.ts @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import type { LeaderboardRankProps } from './LeaderboardRank'; +import { LeaderboardRankView } from './LeaderboardRankView'; +import type { LeaderboardRankHookResult } from './useLeaderboardRank'; + +const defaultArgs: LeaderboardRankProps = { + leaderboardType: 'topStartedReferrers', + rankTextFormat: 'Your rank: {rank}', + unrankedText: 'Complete a referral to get ranked!', +}; + +function normalizeArgs(args: Partial): LeaderboardRankProps { + return { + ...defaultArgs, + ...args, + }; +} + +function formatRankText(props: LeaderboardRankProps, rank?: number) { + return rank + ? (props.rankTextFormat || 'Your rank: {rank}').replace('{rank}', String(rank)) + : props.unrankedText; +} + +function renderView( + args: Partial, + hookProps: Partial = {} +) { + const props = normalizeArgs(args); + const rank = hookProps.rank; + + return LeaderboardRankView({ + ...props, + rank, + rankText: hookProps.rankText ?? formatRankText(props, rank), + loading: hookProps.loading ?? false, + }); +} + +const meta = { + title: 'Components/LeaderboardRank', + component: 'sql-leaderboard-rank', + tags: ['autodocs'], + argTypes: { + leaderboardType: { control: 'text' }, + rankTextFormat: { control: 'text' }, + unrankedText: { control: 'text' }, + }, + render: (args: Partial) => renderView(args, { rank: 7 }), +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Ranked: Story = { + render: (args) => renderView(args, { rank: 2 }), +}; + +export const Unranked: Story = { + render: (args) => renderView(args, { rank: undefined, rankText: normalizeArgs(args).unrankedText }), +}; + +export const Loading: Story = { + render: (args) => renderView(args, { loading: true, rank: undefined }), +}; + +export const CustomFormat: Story = { + args: { + rankTextFormat: 'You are currently #{rank}', + }, + render: (args) => renderView(args, { rank: 12 }), +}; diff --git a/packages/lit-components/src/components/LeaderboardRank/LeaderboardRank.ts b/packages/lit-components/src/components/LeaderboardRank/LeaderboardRank.ts new file mode 100644 index 0000000000..2629420cda --- /dev/null +++ b/packages/lit-components/src/components/LeaderboardRank/LeaderboardRank.ts @@ -0,0 +1,45 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { LeaderboardRankView } from './LeaderboardRankView'; +import { useDemoLeaderboardRank, useLeaderboardRank } from './useLeaderboardRank'; + +export interface LeaderboardRankProps { + leaderboardType: string; + programId?: string; + rankTextFormat: string; + unrankedText: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-leaderboard-rank': HTMLElement; + } +} + +export const LeaderboardRank = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: LeaderboardRankProps = { + leaderboardType: + typeof rawProps.leaderboardType === 'string' + ? rawProps.leaderboardType + : 'topStartedReferrers', + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + rankTextFormat: + typeof rawProps.rankTextFormat === 'string' + ? rawProps.rankTextFormat + : 'Your rank: {rank}', + unrankedText: + typeof rawProps.unrankedText === 'string' + ? rawProps.unrankedText + : 'Complete a referral to get ranked!', + }; + + const hookProps = isDemo() ? useDemoLeaderboardRank(props) : useLeaderboardRank(props); + + return LeaderboardRankView({ ...props, ...hookProps }); + }, + 'sql-leaderboard-rank', + ['leaderboard-type', 'program-id', 'rank-text-format', 'unranked-text'] as const +); diff --git a/packages/lit-components/src/components/LeaderboardRank/LeaderboardRankView.ts b/packages/lit-components/src/components/LeaderboardRank/LeaderboardRankView.ts new file mode 100644 index 0000000000..4197a8dc7e --- /dev/null +++ b/packages/lit-components/src/components/LeaderboardRank/LeaderboardRankView.ts @@ -0,0 +1,49 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { LeaderboardRankProps } from './LeaderboardRank'; +import { LeaderboardRankHookResult } from './useLeaderboardRank'; + +export function LeaderboardRankView(props: LeaderboardRankProps & LeaderboardRankHookResult) { + return html` + +
+ ${props.loading + ? html`${UI.Spinner({})}` + : props.rank + ? html` +
${props.rank}
+ ${props.rankText} + ` + : html`${props.rankText}`} +
+ `; +} diff --git a/packages/lit-components/src/components/LeaderboardRank/index.ts b/packages/lit-components/src/components/LeaderboardRank/index.ts new file mode 100644 index 0000000000..b4af1828b5 --- /dev/null +++ b/packages/lit-components/src/components/LeaderboardRank/index.ts @@ -0,0 +1 @@ +export * from './LeaderboardRank'; diff --git a/packages/lit-components/src/components/LeaderboardRank/useLeaderboardRank.test.ts b/packages/lit-components/src/components/LeaderboardRank/useLeaderboardRank.test.ts new file mode 100644 index 0000000000..72c47a51be --- /dev/null +++ b/packages/lit-components/src/components/LeaderboardRank/useLeaderboardRank.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ data: { viewer: { leaderboardRank: { rank: 4 } } }, loading: false })), +})); + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useLeaderboardRank } from './useLeaderboardRank'; + +describe('useLeaderboardRank', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockClear(); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { leaderboardRank: { rank: 4 } } }, + loading: false, + }); + }); + + it('returns the rank from query data', () => { + const result = useLeaderboardRank({ leaderboardType: 'referrals', unrankedText: 'Unranked' } as any); + + expect(result.rank).toBe(4); + }); + + it('returns the formatted rank text', () => { + const result = useLeaderboardRank({ + leaderboardType: 'referrals', + rankTextFormat: 'Rank #{rank}', + unrankedText: 'Unranked', + } as any); + + expect(result.rankText).toBe('Rank #4'); + }); + + it('returns the unranked text when rank is undefined', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { leaderboardRank: { rank: null } } }, + loading: false, + }); + + const result = useLeaderboardRank({ leaderboardType: 'referrals', unrankedText: 'Unranked' } as any); + + expect(result.rank).toBeUndefined(); + expect(result.rankText).toBe('Unranked'); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { leaderboardRank: { rank: 4 } } }, + loading: true, + }); + + const result = useLeaderboardRank({ leaderboardType: 'referrals', unrankedText: 'Unranked' } as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/LeaderboardRank/useLeaderboardRank.ts b/packages/lit-components/src/components/LeaderboardRank/useLeaderboardRank.ts new file mode 100644 index 0000000000..60e9deb073 --- /dev/null +++ b/packages/lit-components/src/components/LeaderboardRank/useLeaderboardRank.ts @@ -0,0 +1,59 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import type { LeaderboardRankProps } from './LeaderboardRank'; + +interface RankQueryResult { + viewer?: { + leaderboardRank?: { + rank?: number | null; + } | null; + } | null; +} + +export interface LeaderboardRankHookResult { + rank?: number; + rankText: string; + loading: boolean; +} + +const RANK_QUERY = gql` + query getRank($type: String!, $filter: UserLeaderboardFilterInput) { + viewer { + ... on User { + leaderboardRank(type: $type, filter: $filter) { + rank + } + } + } + } +`; + +function getRankText(props: LeaderboardRankProps, rank?: number) { + return rank + ? (props.rankTextFormat || 'Your rank: {rank}').replace('{rank}', String(rank)) + : props.unrankedText; +} + +export function useLeaderboardRank(props: LeaderboardRankProps): LeaderboardRankHookResult { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + + const { data, loading } = useQuery( + RANK_QUERY, + { + type: props.leaderboardType, + filter: programId ? { programId_eq: programId } : {}, + }, + !user?.jwt + ); + + const rank = data?.viewer?.leaderboardRank?.rank ?? undefined; + + return { rank, rankText: getRankText(props, rank), loading }; +} + +export function useDemoLeaderboardRank(props: LeaderboardRankProps): LeaderboardRankHookResult { + const [rank] = useState(7); + return { rank, rankText: getRankText(props, rank), loading: false }; +} diff --git a/packages/lit-components/src/components/LinkButton/LinkButton.stories.ts b/packages/lit-components/src/components/LinkButton/LinkButton.stories.ts new file mode 100644 index 0000000000..be0d49c716 --- /dev/null +++ b/packages/lit-components/src/components/LinkButton/LinkButton.stories.ts @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/LinkButton', + component: 'sql-link-button', + tags: ['autodocs'], + argTypes: { + href: { control: 'text' }, + target: { control: 'select', options: ['_self', '_blank'] }, + 'button-text': { control: 'text' }, + 'button-type': { + control: 'select', + options: ['primary', 'secondary', 'success', 'neutral', 'warning', 'danger'], + }, + size: { control: 'select', options: ['small', 'medium', 'large'] }, + pill: { control: 'boolean' }, + outline: { control: 'boolean' }, + disabled: { control: 'boolean' }, + icon: { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + href: 'https://www.example.com', + 'button-text': 'Click Here', + }, +}; + +export const ExternalLink: Story = { + args: { + href: 'https://www.example.com', + target: '_blank', + 'button-text': 'Open External Link', + }, +}; + +export const Secondary: Story = { + args: { + 'button-type': 'secondary', + 'button-text': 'Secondary Button', + }, +}; + +export const Outline: Story = { + args: { + outline: true, + 'button-text': 'Outline Button', + }, +}; + +export const Pill: Story = { + args: { + pill: true, + 'button-text': 'Pill Button', + }, +}; + +export const WithIcon: Story = { + args: { + icon: 'box-arrow-up-right', + 'button-text': 'Button With Icon', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + 'button-text': 'Large Button', + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + 'button-text': 'Disabled Button', + }, +}; diff --git a/packages/lit-components/src/components/LinkButton/LinkButton.ts b/packages/lit-components/src/components/LinkButton/LinkButton.ts new file mode 100644 index 0000000000..b94ff08f43 --- /dev/null +++ b/packages/lit-components/src/components/LinkButton/LinkButton.ts @@ -0,0 +1,56 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { LinkButtonView } from './LinkButtonView'; + +export interface LinkButtonProps { + href: string; + target?: '_self' | '_blank'; + buttonText?: string; + buttonType?: 'primary' | 'secondary' | 'success' | 'neutral' | 'warning' | 'danger'; + size?: 'small' | 'medium' | 'large'; + pill?: boolean; + outline?: boolean; + disabled?: boolean; + icon?: string; +} + +const parseBoolean = (value: unknown, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-link-button': HTMLElement; + } +} + +export const LinkButton = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: LinkButtonProps = { + href: typeof rawProps.href === 'string' ? rawProps.href : '', + target: rawProps.target === '_blank' ? '_blank' : '_self', + buttonText: typeof rawProps.buttonText === 'string' ? rawProps.buttonText : 'Click Here', + buttonType: + rawProps.buttonType === 'secondary' || + rawProps.buttonType === 'success' || + rawProps.buttonType === 'neutral' || + rawProps.buttonType === 'warning' || + rawProps.buttonType === 'danger' + ? rawProps.buttonType + : 'primary', + size: + rawProps.size === 'small' || rawProps.size === 'large' ? rawProps.size : 'medium', + pill: parseBoolean(rawProps.pill, false), + outline: parseBoolean(rawProps.outline, false), + disabled: parseBoolean(rawProps.disabled, false), + icon: typeof rawProps.icon === 'string' ? rawProps.icon : undefined, + }; + + return LinkButtonView(props); + }, + 'sql-link-button', + ['href', 'target', 'button-text', 'button-type', 'size', 'pill', 'outline', 'disabled', 'icon'] as const +); diff --git a/packages/lit-components/src/components/LinkButton/LinkButtonView.ts b/packages/lit-components/src/components/LinkButton/LinkButtonView.ts new file mode 100644 index 0000000000..88b96c0151 --- /dev/null +++ b/packages/lit-components/src/components/LinkButton/LinkButtonView.ts @@ -0,0 +1,33 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { ButtonVariant } from '../../ui'; +import { LinkButtonProps } from './LinkButton'; + +export function LinkButtonView(props: LinkButtonProps) { + return html` + + + ${UI.Button({ + variant: props.buttonType as ButtonVariant, + size: props.size, + pill: props.pill, + outline: props.outline, + disabled: props.disabled, + prefix: props.icon ? UI.Icon({ name: props.icon }) : undefined, + children: props.buttonText, + })} + + `; +} diff --git a/packages/lit-components/src/components/LinkButton/index.ts b/packages/lit-components/src/components/LinkButton/index.ts new file mode 100644 index 0000000000..016ed7fc29 --- /dev/null +++ b/packages/lit-components/src/components/LinkButton/index.ts @@ -0,0 +1 @@ +export * from './LinkButton'; diff --git a/packages/lit-components/src/components/LogoutCurrentUser/LogoutCurrentUser.stories.ts b/packages/lit-components/src/components/LogoutCurrentUser/LogoutCurrentUser.stories.ts new file mode 100644 index 0000000000..c318f7fb4d --- /dev/null +++ b/packages/lit-components/src/components/LogoutCurrentUser/LogoutCurrentUser.stories.ts @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/LogoutCurrentUser', + component: 'sql-logout-current-user', + tags: ['autodocs'], + argTypes: { + 'button-text': { control: 'text' }, + 'button-type': { control: 'select', options: ['primary', 'secondary', 'danger', 'text'] }, + size: { control: 'select', options: ['small', 'medium', 'large'] }, + }, + render: (args) => + html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Primary: Story = { + args: { + 'button-type': 'primary', + }, +}; + +export const Danger: Story = { + args: { + 'button-type': 'danger', + }, +}; + +export const Small: Story = { + args: { + size: 'small', + }, +}; diff --git a/packages/lit-components/src/components/LogoutCurrentUser/LogoutCurrentUser.ts b/packages/lit-components/src/components/LogoutCurrentUser/LogoutCurrentUser.ts new file mode 100644 index 0000000000..e0e3bc9aff --- /dev/null +++ b/packages/lit-components/src/components/LogoutCurrentUser/LogoutCurrentUser.ts @@ -0,0 +1,39 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { LogoutCurrentUserView } from './LogoutCurrentUserView'; +import { useDemoLogoutCurrentUser, useLogoutCurrentUser } from './useLogoutCurrentUser'; + +export interface LogoutCurrentUserProps { + buttonText: string; + buttonType: 'primary' | 'secondary' | 'danger' | 'text'; + size: 'small' | 'medium' | 'large'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-logout-current-user': HTMLElement; + } +} + +export const LogoutCurrentUser = useComponent( + (host) => { + const rawProps = getProps(host) as Partial; + const buttonType = rawProps.buttonType; + const size = rawProps.size; + const props: LogoutCurrentUserProps = { + buttonText: rawProps.buttonText || 'Logout', + buttonType: + buttonType === 'primary' || buttonType === 'secondary' || buttonType === 'danger' + ? buttonType + : 'text', + size: size === 'small' || size === 'large' ? size : 'medium', + }; + + const hookProps = isDemo() ? useDemoLogoutCurrentUser() : useLogoutCurrentUser(); + + return LogoutCurrentUserView({ ...props, ...hookProps }); + }, + 'sql-logout-current-user', + ['button-text', 'button-type', 'size'] as const +); diff --git a/packages/lit-components/src/components/LogoutCurrentUser/LogoutCurrentUserView.ts b/packages/lit-components/src/components/LogoutCurrentUser/LogoutCurrentUserView.ts new file mode 100644 index 0000000000..6a8b07b336 --- /dev/null +++ b/packages/lit-components/src/components/LogoutCurrentUser/LogoutCurrentUserView.ts @@ -0,0 +1,24 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { ButtonVariant } from '../../ui'; +import { LogoutCurrentUserProps } from './LogoutCurrentUser'; +import { useLogoutCurrentUser } from './useLogoutCurrentUser'; + +export function LogoutCurrentUserView( + props: LogoutCurrentUserProps & ReturnType +) { + return html` + + ${UI.Button({ + variant: (props.buttonType === 'text' ? 'text' : props.buttonType) as ButtonVariant, + size: props.size, + onClick: props.onClick, + prefix: UI.Icon({ name: 'box-arrow-right' }), + children: props.buttonText, + })} + `; +} diff --git a/packages/lit-components/src/components/LogoutCurrentUser/index.ts b/packages/lit-components/src/components/LogoutCurrentUser/index.ts new file mode 100644 index 0000000000..484b7f7e8d --- /dev/null +++ b/packages/lit-components/src/components/LogoutCurrentUser/index.ts @@ -0,0 +1 @@ +export * from './LogoutCurrentUser'; diff --git a/packages/lit-components/src/components/LogoutCurrentUser/useLogoutCurrentUser.test.ts b/packages/lit-components/src/components/LogoutCurrentUser/useLogoutCurrentUser.test.ts new file mode 100644 index 0000000000..09afe01ac3 --- /dev/null +++ b/packages/lit-components/src/components/LogoutCurrentUser/useLogoutCurrentUser.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useLogoutCurrentUser } from './useLogoutCurrentUser'; + +describe('useLogoutCurrentUser', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + vi.stubGlobal('window', {} as Window & { squatchToken?: string }); + }); + + it('should return an onClick handler', () => { + const result = useLogoutCurrentUser(); + + expect(typeof result.onClick).toBe('function'); + }); + + it('should dispatch a logout event on click', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = useLogoutCurrentUser(); + + result.onClick(); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:logout'); + expect(event.bubbles).toBe(true); + expect(event.composed).toBe(true); + }); + + it('should clear window.squatchToken on click', () => { + (window as Window & { squatchToken?: string }).squatchToken = 'test-token'; + const result = useLogoutCurrentUser(); + + result.onClick(); + + expect((window as Window & { squatchToken?: string }).squatchToken).toBeUndefined(); + }); +}); diff --git a/packages/lit-components/src/components/LogoutCurrentUser/useLogoutCurrentUser.ts b/packages/lit-components/src/components/LogoutCurrentUser/useLogoutCurrentUser.ts new file mode 100644 index 0000000000..23fa0bf5d3 --- /dev/null +++ b/packages/lit-components/src/components/LogoutCurrentUser/useLogoutCurrentUser.ts @@ -0,0 +1,23 @@ +export function useLogoutCurrentUser() { + function onClick() { + const event = new CustomEvent('sq:logout', { + bubbles: true, + composed: true, + }); + document.dispatchEvent(event); + try { + // @ts-expect-error global squatch state + if (window.squatchToken) window.squatchToken = undefined; + } catch { + return undefined; + } + } + + return { onClick }; +} + +export function useDemoLogoutCurrentUser(): ReturnType { + return { + onClick: () => undefined, + }; +} diff --git a/packages/lit-components/src/components/MarketingEmailsCheckbox/MarketingEmailsCheckbox.stories.ts b/packages/lit-components/src/components/MarketingEmailsCheckbox/MarketingEmailsCheckbox.stories.ts new file mode 100644 index 0000000000..8c9dedc879 --- /dev/null +++ b/packages/lit-components/src/components/MarketingEmailsCheckbox/MarketingEmailsCheckbox.stories.ts @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/MarketingEmailsCheckbox', + component: 'sql-marketing-emails-checkbox', + tags: ['autodocs'], + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Checked: Story = { + args: { + 'default-checked': true, + }, +}; + +export const CustomLabel: Story = { + args: { + label: 'Send me product news and offers', + }, +}; diff --git a/packages/lit-components/src/components/MarketingEmailsCheckbox/MarketingEmailsCheckbox.ts b/packages/lit-components/src/components/MarketingEmailsCheckbox/MarketingEmailsCheckbox.ts new file mode 100644 index 0000000000..5d418fd4d6 --- /dev/null +++ b/packages/lit-components/src/components/MarketingEmailsCheckbox/MarketingEmailsCheckbox.ts @@ -0,0 +1,39 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { MarketingEmailsCheckboxView } from './MarketingEmailsCheckboxView'; + +export interface MarketingEmailsCheckboxProps { + label: string; + fieldName: string; + defaultChecked: boolean; +} + +function getBooleanAttribute(host: HTMLElement, name: string, defaultValue = false) { + if (!host.hasAttribute(name)) { + return defaultValue; + } + + const value = host.getAttribute(name); + return value === '' || value === 'true'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-marketing-emails-checkbox': HTMLElement; + } +} + +export const MarketingEmailsCheckbox = useComponent( + (host) => { + const rawProps = getProps(host); + const props: MarketingEmailsCheckboxProps = { + label: rawProps.label || 'I agree to receive marketing emails', + fieldName: rawProps.fieldName || 'marketingOptIn', + defaultChecked: getBooleanAttribute(host, 'default-checked', false), + }; + + return MarketingEmailsCheckboxView(props); + }, + 'sql-marketing-emails-checkbox', + ['label', 'field-name', 'default-checked'] as const +); diff --git a/packages/lit-components/src/components/MarketingEmailsCheckbox/MarketingEmailsCheckboxView.ts b/packages/lit-components/src/components/MarketingEmailsCheckbox/MarketingEmailsCheckboxView.ts new file mode 100644 index 0000000000..b0ac446d5b --- /dev/null +++ b/packages/lit-components/src/components/MarketingEmailsCheckbox/MarketingEmailsCheckboxView.ts @@ -0,0 +1,14 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { MarketingEmailsCheckboxProps } from './MarketingEmailsCheckbox'; + +export function MarketingEmailsCheckboxView(props: MarketingEmailsCheckboxProps) { + return html` + + ${UI.Checkbox({ name: props.fieldName, checked: props.defaultChecked, children: props.label })} + `; +} diff --git a/packages/lit-components/src/components/MarketingEmailsCheckbox/index.ts b/packages/lit-components/src/components/MarketingEmailsCheckbox/index.ts new file mode 100644 index 0000000000..aa189c5281 --- /dev/null +++ b/packages/lit-components/src/components/MarketingEmailsCheckbox/index.ts @@ -0,0 +1 @@ +export * from './MarketingEmailsCheckbox'; diff --git a/packages/lit-components/src/components/NameFields/NameFields.stories.ts b/packages/lit-components/src/components/NameFields/NameFields.stories.ts new file mode 100644 index 0000000000..06e55513d2 --- /dev/null +++ b/packages/lit-components/src/components/NameFields/NameFields.stories.ts @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/NameFields', + component: 'sql-name-fields', + tags: ['autodocs'], + argTypes: { + 'first-name-label': { control: 'text' }, + 'last-name-label': { control: 'text' }, + 'first-name-placeholder': { control: 'text' }, + 'last-name-placeholder': { control: 'text' }, + 'field-required': { control: 'boolean' }, + 'field-disabled': { control: 'boolean' }, + 'field-size': { control: 'select', options: ['small', 'medium', 'large'] }, + layout: { control: 'select', options: ['horizontal', 'vertical'] }, + }, + render: (args) => html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Vertical: Story = { + args: { + layout: 'vertical', + }, +}; + +export const Required: Story = { + args: { + 'field-required': true, + }, +}; + +export const Disabled: Story = { + args: { + 'field-disabled': true, + }, +}; + +export const WithPlaceholders: Story = { + args: { + 'first-name-placeholder': 'Jane', + 'last-name-placeholder': 'Doe', + }, +}; + +export const WithErrors: Story = { + args: { + 'field-required': true, + }, +}; diff --git a/packages/lit-components/src/components/NameFields/NameFields.ts b/packages/lit-components/src/components/NameFields/NameFields.ts new file mode 100644 index 0000000000..39ab4e8024 --- /dev/null +++ b/packages/lit-components/src/components/NameFields/NameFields.ts @@ -0,0 +1,62 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { NameFieldsView } from './NameFieldsView'; +import { useNameFields, useDemoNameFields } from './useNameFields'; + +export interface NameFieldsProps { + firstNameLabel: string; + lastNameLabel: string; + firstNamePlaceholder?: string; + lastNamePlaceholder?: string; + fieldRequired: boolean; + fieldDisabled: boolean; + fieldSize: 'small' | 'medium' | 'large'; + layout: 'horizontal' | 'vertical'; +} + +function getBooleanAttribute(host: HTMLElement, name: string, defaultValue = false) { + if (!host.hasAttribute(name)) { + return defaultValue; + } + + const value = host.getAttribute(name); + return value === '' || value === 'true'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-name-fields': HTMLElement; + } +} + +export const NameFields = useComponent( + (host) => { + const rawProps = getProps(host); + const props: NameFieldsProps = { + firstNameLabel: rawProps.firstNameLabel || 'First Name', + lastNameLabel: rawProps.lastNameLabel || 'Last Name', + firstNamePlaceholder: rawProps.firstNamePlaceholder || undefined, + lastNamePlaceholder: rawProps.lastNamePlaceholder || undefined, + fieldRequired: getBooleanAttribute(host, 'field-required', false), + fieldDisabled: getBooleanAttribute(host, 'field-disabled', false), + fieldSize: (rawProps.fieldSize as NameFieldsProps['fieldSize']) || 'medium', + layout: (rawProps.layout as NameFieldsProps['layout']) || 'horizontal', + }; + + const hookProps = isDemo() ? useDemoNameFields(props) : useNameFields(props); + + return NameFieldsView({ ...props, ...hookProps }); + }, + 'sql-name-fields', + [ + 'first-name-label', + 'last-name-label', + 'first-name-placeholder', + 'last-name-placeholder', + 'field-required', + 'field-disabled', + 'field-size', + 'layout', + ] as const +); diff --git a/packages/lit-components/src/components/NameFields/NameFieldsView.ts b/packages/lit-components/src/components/NameFields/NameFieldsView.ts new file mode 100644 index 0000000000..cac77dcf4a --- /dev/null +++ b/packages/lit-components/src/components/NameFields/NameFieldsView.ts @@ -0,0 +1,62 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { NameFieldsProps } from './NameFields'; +import { useNameFields } from './useNameFields'; + +const styles = ` + :host { + display: block; + } + + .name-fields { + display: flex; + gap: var(--sl-spacing-medium); + } + + .field { + flex: 1; + } + + sl-input { + width: 100%; + } +`; + +export function NameFieldsView(props: NameFieldsProps & ReturnType) { + return html` + +
+
+ ${UI.Input({ + label: props.firstNameLabel, + name: 'firstName', + placeholder: props.firstNamePlaceholder || '', + required: props.fieldRequired, + disabled: props.fieldDisabled, + size: props.fieldSize, + value: props.firstName, + onInput: props.onFirstNameInput, + error: props.firstNameError, + })} +
+
+ ${UI.Input({ + label: props.lastNameLabel, + name: 'lastName', + placeholder: props.lastNamePlaceholder || '', + required: props.fieldRequired, + disabled: props.fieldDisabled, + size: props.fieldSize, + value: props.lastName, + onInput: props.onLastNameInput, + error: props.lastNameError, + })} +
+
+ `; +} diff --git a/packages/lit-components/src/components/NameFields/index.ts b/packages/lit-components/src/components/NameFields/index.ts new file mode 100644 index 0000000000..46605a2798 --- /dev/null +++ b/packages/lit-components/src/components/NameFields/index.ts @@ -0,0 +1 @@ +export * from './NameFields'; diff --git a/packages/lit-components/src/components/NameFields/useNameFields.test.ts b/packages/lit-components/src/components/NameFields/useNameFields.test.ts new file mode 100644 index 0000000000..ec0930652a --- /dev/null +++ b/packages/lit-components/src/components/NameFields/useNameFields.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useNameFields } from './useNameFields'; + +describe('useNameFields', () => { + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + }); + + it('should return initial empty state', () => { + const result = useNameFields({ fieldRequired: false } as any); + + expect(result.firstName).toBe(''); + expect(result.lastName).toBe(''); + expect(result.firstNameError).toBe(''); + expect(result.lastNameError).toBe(''); + }); + + it('should have input handlers', () => { + const result = useNameFields({ fieldRequired: true } as any); + + result.onFirstNameInput({ target: { value: '' } } as unknown as Event); + result.onLastNameInput({ target: { value: '' } } as unknown as Event); + + expect(typeof result.onFirstNameInput).toBe('function'); + expect(typeof result.onLastNameInput).toBe('function'); + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('First name is required'); + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith('Last name is required'); + }); +}); diff --git a/packages/lit-components/src/components/NameFields/useNameFields.ts b/packages/lit-components/src/components/NameFields/useNameFields.ts new file mode 100644 index 0000000000..f678a464fb --- /dev/null +++ b/packages/lit-components/src/components/NameFields/useNameFields.ts @@ -0,0 +1,48 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { NameFieldsProps } from './NameFields'; + +export function useNameFields(props: NameFieldsProps) { + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [firstNameError, setFirstNameError] = useState(''); + const [lastNameError, setLastNameError] = useState(''); + + function onFirstNameInput(e: Event) { + const target = e.target as HTMLInputElement; + setFirstName(target.value); + if (props.fieldRequired && !target.value) setFirstNameError('First name is required'); + else setFirstNameError(''); + } + + function onLastNameInput(e: Event) { + const target = e.target as HTMLInputElement; + setLastName(target.value); + if (props.fieldRequired && !target.value) setLastNameError('Last name is required'); + else setLastNameError(''); + } + + return { firstName, lastName, firstNameError, lastNameError, onFirstNameInput, onLastNameInput }; +} + +export function useDemoNameFields(props: NameFieldsProps): ReturnType { + const [firstName, setFirstName] = useState('Jane'); + const [lastName, setLastName] = useState('Doe'); + const [firstNameError, setFirstNameError] = useState(''); + const [lastNameError, setLastNameError] = useState(''); + + function onFirstNameInput(e: Event) { + const target = e.target as HTMLInputElement; + setFirstName(target.value); + if (props.fieldRequired && !target.value) setFirstNameError('First name is required'); + else setFirstNameError(''); + } + + function onLastNameInput(e: Event) { + const target = e.target as HTMLInputElement; + setLastName(target.value); + if (props.fieldRequired && !target.value) setLastNameError('Last name is required'); + else setLastNameError(''); + } + + return { firstName, lastName, firstNameError, lastNameError, onFirstNameInput, onLastNameInput }; +} diff --git a/packages/lit-components/src/components/NavigationMenu/NavigationMenu.stories.ts b/packages/lit-components/src/components/NavigationMenu/NavigationMenu.stories.ts new file mode 100644 index 0000000000..b8074c474e --- /dev/null +++ b/packages/lit-components/src/components/NavigationMenu/NavigationMenu.stories.ts @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { UI } from '../../ui'; +import '../../index'; + +const meta: Meta = { + title: 'Components/NavigationMenu', + component: 'sql-navigation-menu', + tags: ['autodocs'], + argTypes: { + 'menu-style': { control: 'select', options: ['tabs', 'dropdown'] }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const TabsStyle: Story = { + render: () => html` + + ${UI.Button({ variant: 'text', children: 'Overview' })} + ${UI.Button({ variant: 'text', children: 'Rewards' })} + ${UI.Button({ variant: 'text', children: 'History' })} + + `, +}; + +export const DropdownStyle: Story = { + render: () => html` + + ${UI.MenuItem({ children: 'Overview' })} + ${UI.MenuItem({ children: 'Rewards' })} + ${UI.MenuItem({ children: 'History' })} + + `, +}; diff --git a/packages/lit-components/src/components/NavigationMenu/NavigationMenu.ts b/packages/lit-components/src/components/NavigationMenu/NavigationMenu.ts new file mode 100644 index 0000000000..200c73a19f --- /dev/null +++ b/packages/lit-components/src/components/NavigationMenu/NavigationMenu.ts @@ -0,0 +1,30 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { NavigationMenuView } from './NavigationMenuView'; +import { useDemoNavigationMenu, useNavigationMenu } from './useNavigationMenu'; + +export interface NavigationMenuProps { + menuStyle?: 'tabs' | 'dropdown'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-navigation-menu': HTMLElement; + } +} + +export const NavigationMenu = useComponent( + (host) => { + const props: NavigationMenuProps = { + menuStyle: 'tabs', + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoNavigationMenu(props) : useNavigationMenu(props); + + return NavigationMenuView({ ...props, ...hookProps }); + }, + 'sql-navigation-menu', + ['menu-style'] as const +); diff --git a/packages/lit-components/src/components/NavigationMenu/NavigationMenuView.ts b/packages/lit-components/src/components/NavigationMenu/NavigationMenuView.ts new file mode 100644 index 0000000000..14b83ba527 --- /dev/null +++ b/packages/lit-components/src/components/NavigationMenu/NavigationMenuView.ts @@ -0,0 +1,36 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { NavigationMenuProps } from './NavigationMenu'; +import { useNavigationMenu } from './useNavigationMenu'; + +export function NavigationMenuView( + props: NavigationMenuProps & ReturnType +) { + return html` + + ${props.menuStyle === 'dropdown' + ? html` + ${UI.Dropdown({ + open: props.isOpen, + trigger: UI.Button({ caret: true, onClick: props.toggleMenu, children: 'Menu' }), + children: html`${UI.Menu({ children: html`` })}`, + })} + ` + : html` + + `} + `; +} diff --git a/packages/lit-components/src/components/NavigationMenu/index.ts b/packages/lit-components/src/components/NavigationMenu/index.ts new file mode 100644 index 0000000000..3c7aa8831c --- /dev/null +++ b/packages/lit-components/src/components/NavigationMenu/index.ts @@ -0,0 +1 @@ +export * from './NavigationMenu'; diff --git a/packages/lit-components/src/components/NavigationMenu/useNavigationMenu.test.ts b/packages/lit-components/src/components/NavigationMenu/useNavigationMenu.test.ts new file mode 100644 index 0000000000..584bcd76a5 --- /dev/null +++ b/packages/lit-components/src/components/NavigationMenu/useNavigationMenu.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useNavigationMenu } from './useNavigationMenu'; + +describe('useNavigationMenu', () => { + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + }); + + it('should return the initial state', () => { + const result = useNavigationMenu({} as never); + + expect(result.selectedIndex).toBe(0); + expect(result.isOpen).toBe(false); + expect(universalHooksMock.useState).toHaveBeenNthCalledWith(1, 0); + expect(universalHooksMock.useState).toHaveBeenNthCalledWith(2, false); + }); + + it('should update the selected index and close the menu on select', () => { + const result = useNavigationMenu({} as never); + + result.onSelect(2); + + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(2); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith(false); + }); + + it('should toggle the menu open state', () => { + const result = useNavigationMenu({} as never); + + result.toggleMenu(); + + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith(true); + }); +}); diff --git a/packages/lit-components/src/components/NavigationMenu/useNavigationMenu.ts b/packages/lit-components/src/components/NavigationMenu/useNavigationMenu.ts new file mode 100644 index 0000000000..ae1ff945c5 --- /dev/null +++ b/packages/lit-components/src/components/NavigationMenu/useNavigationMenu.ts @@ -0,0 +1,36 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { NavigationMenuProps } from './NavigationMenu'; + +export function useNavigationMenu(_props: NavigationMenuProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const [isOpen, setIsOpen] = useState(false); + + function onSelect(index: number) { + setSelectedIndex(index); + setIsOpen(false); + } + + function toggleMenu() { + setIsOpen(!isOpen); + } + + return { selectedIndex, isOpen, onSelect, toggleMenu }; +} + +export function useDemoNavigationMenu( + _props: NavigationMenuProps +): ReturnType { + const [selectedIndex, setSelectedIndex] = useState(0); + const [isOpen, setIsOpen] = useState(false); + + function onSelect(index: number) { + setSelectedIndex(index); + setIsOpen(false); + } + + function toggleMenu() { + setIsOpen(!isOpen); + } + + return { selectedIndex, isOpen, onSelect, toggleMenu }; +} diff --git a/packages/lit-components/src/components/NavigationSidebar/NavigationSidebar.stories.ts b/packages/lit-components/src/components/NavigationSidebar/NavigationSidebar.stories.ts new file mode 100644 index 0000000000..87362b0337 --- /dev/null +++ b/packages/lit-components/src/components/NavigationSidebar/NavigationSidebar.stories.ts @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/NavigationSidebar', + component: 'sql-navigation-sidebar', + tags: ['autodocs'], + argTypes: { + header: { control: 'text' }, + 'background-color': { control: 'color' }, + 'border-color': { control: 'color' }, + width: { control: 'text' }, + }, + render: (args) => html` +
+ +
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + width: '250px', + }, +}; + +export const WithHeader: Story = { + args: { + header: 'Navigation', + }, +}; + +export const CustomBackground: Story = { + args: { + header: 'Navigation', + 'background-color': '#f8fafc', + 'border-color': '#cbd5e1', + }, +}; + +export const CustomWidth: Story = { + args: { + header: 'Navigation', + width: '320px', + }, +}; + +export const WithItems: Story = { + render: () => html` +
+ + + + + +
+ `, +}; diff --git a/packages/lit-components/src/components/NavigationSidebar/NavigationSidebar.ts b/packages/lit-components/src/components/NavigationSidebar/NavigationSidebar.ts new file mode 100644 index 0000000000..c7892c1534 --- /dev/null +++ b/packages/lit-components/src/components/NavigationSidebar/NavigationSidebar.ts @@ -0,0 +1,29 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { NavigationSidebarView } from './NavigationSidebarView'; + +export interface NavigationSidebarProps { + header?: string; + backgroundColor?: string; + borderColor?: string; + width?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-navigation-sidebar': HTMLElement; + } +} + +export const NavigationSidebar = useComponent( + (host) => { + const props: NavigationSidebarProps = { + width: '250px', + ...getProps(host), + }; + + return NavigationSidebarView(props); + }, + 'sql-navigation-sidebar', + ['header', 'background-color', 'border-color', 'width'] as const +); diff --git a/packages/lit-components/src/components/NavigationSidebar/NavigationSidebarView.ts b/packages/lit-components/src/components/NavigationSidebar/NavigationSidebarView.ts new file mode 100644 index 0000000000..a19d42e96d --- /dev/null +++ b/packages/lit-components/src/components/NavigationSidebar/NavigationSidebarView.ts @@ -0,0 +1,39 @@ +import { html } from 'lit'; +import type { NavigationSidebarProps } from './NavigationSidebar'; + +export function NavigationSidebarView(props: NavigationSidebarProps) { + return html` + + + `; +} diff --git a/packages/lit-components/src/components/NavigationSidebar/index.ts b/packages/lit-components/src/components/NavigationSidebar/index.ts new file mode 100644 index 0000000000..4b6d446b11 --- /dev/null +++ b/packages/lit-components/src/components/NavigationSidebar/index.ts @@ -0,0 +1 @@ +export * from './NavigationSidebar'; diff --git a/packages/lit-components/src/components/NavigationSidebarItem/NavigationSidebarItem.stories.ts b/packages/lit-components/src/components/NavigationSidebarItem/NavigationSidebarItem.stories.ts new file mode 100644 index 0000000000..64f95c0bc0 --- /dev/null +++ b/packages/lit-components/src/components/NavigationSidebarItem/NavigationSidebarItem.stories.ts @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/NavigationSidebarItem', + component: 'sql-navigation-sidebar-item', + tags: ['autodocs'], + argTypes: { + label: { control: 'text' }, + icon: { control: 'text' }, + path: { control: 'text' }, + selected: { control: 'boolean' }, + disabled: { control: 'boolean' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'Dashboard', + path: '/dashboard', + }, +}; + +export const Selected: Story = { + args: { + label: 'Rewards', + path: '/rewards', + selected: true, + }, +}; + +export const WithIcon: Story = { + args: { + label: 'Settings', + icon: 'gear', + path: '/settings', + }, +}; + +export const Disabled: Story = { + args: { + label: 'Disabled Item', + disabled: true, + }, +}; + +export const Group: Story = { + render: () => html` +
+ + + + +
+ `, +}; diff --git a/packages/lit-components/src/components/NavigationSidebarItem/NavigationSidebarItem.ts b/packages/lit-components/src/components/NavigationSidebarItem/NavigationSidebarItem.ts new file mode 100644 index 0000000000..bc202e784c --- /dev/null +++ b/packages/lit-components/src/components/NavigationSidebarItem/NavigationSidebarItem.ts @@ -0,0 +1,37 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { NavigationSidebarItemView } from './NavigationSidebarItemView'; +import { useDemoNavigationSidebarItem, useNavigationSidebarItem } from './useNavigationSidebarItem'; + +export interface NavigationSidebarItemProps { + label: string; + icon?: string; + path?: string; + selected?: boolean; + disabled?: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-navigation-sidebar-item': HTMLElement; + } +} + +export const NavigationSidebarItem = useComponent( + (host) => { + const rawProps = getProps(host); + const props: NavigationSidebarItemProps = { + ...rawProps, + label: rawProps.label || '', + selected: host.hasAttribute('selected') && host.getAttribute('selected') !== 'false', + disabled: host.hasAttribute('disabled') && host.getAttribute('disabled') !== 'false', + }; + + const hookProps = isDemo() ? useDemoNavigationSidebarItem(props) : useNavigationSidebarItem(props); + + return NavigationSidebarItemView({ ...props, ...hookProps }); + }, + 'sql-navigation-sidebar-item', + ['label', 'icon', 'path', 'selected', 'disabled'] as const +); diff --git a/packages/lit-components/src/components/NavigationSidebarItem/NavigationSidebarItemView.ts b/packages/lit-components/src/components/NavigationSidebarItem/NavigationSidebarItemView.ts new file mode 100644 index 0000000000..21ffdc33ba --- /dev/null +++ b/packages/lit-components/src/components/NavigationSidebarItem/NavigationSidebarItemView.ts @@ -0,0 +1,53 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { NavigationSidebarItemProps } from './NavigationSidebarItem'; +import { useNavigationSidebarItem } from './useNavigationSidebarItem'; + +export function NavigationSidebarItemView( + props: NavigationSidebarItemProps & ReturnType +) { + return html` + + + `; +} diff --git a/packages/lit-components/src/components/NavigationSidebarItem/index.ts b/packages/lit-components/src/components/NavigationSidebarItem/index.ts new file mode 100644 index 0000000000..afb7d446bd --- /dev/null +++ b/packages/lit-components/src/components/NavigationSidebarItem/index.ts @@ -0,0 +1 @@ +export * from './NavigationSidebarItem'; diff --git a/packages/lit-components/src/components/NavigationSidebarItem/useNavigationSidebarItem.test.ts b/packages/lit-components/src/components/NavigationSidebarItem/useNavigationSidebarItem.test.ts new file mode 100644 index 0000000000..d174ab49b9 --- /dev/null +++ b/packages/lit-components/src/components/NavigationSidebarItem/useNavigationSidebarItem.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useNavigationSidebarItem } from './useNavigationSidebarItem'; + +describe('useNavigationSidebarItem', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return an onClick handler', () => { + const result = useNavigationSidebarItem({ label: 'Dashboard', path: '/dashboard' }); + + expect(typeof result.onClick).toBe('function'); + }); + + it('should dispatch a navigate event with the provided path', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = useNavigationSidebarItem({ label: 'Dashboard', path: '/dashboard' }); + + result.onClick(); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:navigate'); + expect(event.detail).toEqual({ path: '/dashboard' }); + expect(event.bubbles).toBe(true); + expect(event.composed).toBe(true); + }); + + it('should not dispatch when disabled', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = useNavigationSidebarItem({ label: 'Dashboard', path: '/dashboard', disabled: true }); + + result.onClick(); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/lit-components/src/components/NavigationSidebarItem/useNavigationSidebarItem.ts b/packages/lit-components/src/components/NavigationSidebarItem/useNavigationSidebarItem.ts new file mode 100644 index 0000000000..21b7d4ddce --- /dev/null +++ b/packages/lit-components/src/components/NavigationSidebarItem/useNavigationSidebarItem.ts @@ -0,0 +1,25 @@ +import type { NavigationSidebarItemProps } from './NavigationSidebarItem'; + +export function useNavigationSidebarItem(props: NavigationSidebarItemProps) { + function onClick() { + if (props.disabled) return; + + const event = new CustomEvent('sq:navigate', { + bubbles: true, + composed: true, + detail: { path: props.path }, + }); + + document.dispatchEvent(event); + } + + return { onClick }; +} + +export function useDemoNavigationSidebarItem( + _props: NavigationSidebarItemProps +): ReturnType { + return { + onClick: () => undefined, + }; +} diff --git a/packages/lit-components/src/components/Pagination/Pagination.stories.ts b/packages/lit-components/src/components/Pagination/Pagination.stories.ts new file mode 100644 index 0000000000..99dc84cd7c --- /dev/null +++ b/packages/lit-components/src/components/Pagination/Pagination.stories.ts @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/Pagination', + component: 'sql-pagination', + tags: ['autodocs'], + argTypes: { + 'current-page': { control: 'number' }, + 'total-pages': { control: 'number' }, + 'show-previous-label': { control: 'text' }, + 'show-next-label': { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + 'current-page': 1, + 'total-pages': 1, + }, +}; + +export const MultiplePagesStart: Story = { + args: { + 'current-page': 1, + 'total-pages': 5, + }, +}; + +export const MultiplePagesMiddle: Story = { + args: { + 'current-page': 3, + 'total-pages': 5, + }, +}; + +export const MultiplePagesEnd: Story = { + args: { + 'current-page': 5, + 'total-pages': 5, + }, +}; + +export const SinglePage: Story = { + args: { + 'current-page': 1, + 'total-pages': 1, + }, +}; diff --git a/packages/lit-components/src/components/Pagination/Pagination.ts b/packages/lit-components/src/components/Pagination/Pagination.ts new file mode 100644 index 0000000000..ff8759d48f --- /dev/null +++ b/packages/lit-components/src/components/Pagination/Pagination.ts @@ -0,0 +1,43 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PaginationView } from './PaginationView'; +import { useDemoPagination, usePagination } from './usePagination'; + +export interface PaginationProps { + currentPage?: number; + totalPages?: number; + showPreviousLabel?: string; + showNextLabel?: string; +} + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim() !== '') return Number(value); + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-pagination': HTMLElement; + } +} + +export const Pagination = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PaginationProps = { + currentPage: parseNumber(rawProps.currentPage, 1), + totalPages: parseNumber(rawProps.totalPages, 1), + showPreviousLabel: + typeof rawProps.showPreviousLabel === 'string' ? rawProps.showPreviousLabel : 'Previous', + showNextLabel: typeof rawProps.showNextLabel === 'string' ? rawProps.showNextLabel : 'Next', + }; + + const hookProps = isDemo() ? useDemoPagination(props) : usePagination(props); + + return PaginationView({ ...props, ...hookProps }); + }, + 'sql-pagination', + ['current-page', 'total-pages', 'show-previous-label', 'show-next-label'] as const +); diff --git a/packages/lit-components/src/components/Pagination/PaginationView.ts b/packages/lit-components/src/components/Pagination/PaginationView.ts new file mode 100644 index 0000000000..e928e11c65 --- /dev/null +++ b/packages/lit-components/src/components/Pagination/PaginationView.ts @@ -0,0 +1,45 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PaginationProps } from './Pagination'; +import { usePagination } from './usePagination'; + +export function PaginationView(props: PaginationProps & ReturnType) { + const hookProps = props; + + return html` + + + `; +} diff --git a/packages/lit-components/src/components/Pagination/index.ts b/packages/lit-components/src/components/Pagination/index.ts new file mode 100644 index 0000000000..e016c96b72 --- /dev/null +++ b/packages/lit-components/src/components/Pagination/index.ts @@ -0,0 +1 @@ +export * from './Pagination'; diff --git a/packages/lit-components/src/components/Pagination/usePagination.test.ts b/packages/lit-components/src/components/Pagination/usePagination.test.ts new file mode 100644 index 0000000000..038a9a7321 --- /dev/null +++ b/packages/lit-components/src/components/Pagination/usePagination.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePagination } from './usePagination'; + +describe('usePagination', () => { + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + }); + + it('should return initial page 1', () => { + const result = usePagination({ currentPage: 1, totalPages: 5 }); + + expect(result.page).toBe(1); + expect(result.totalPages).toBe(5); + expect(result.hasPrev).toBe(false); + expect(result.hasNext).toBe(true); + }); + + it('should handle single page', () => { + const result = usePagination({ currentPage: 1, totalPages: 1 }); + + expect(result.hasPrev).toBe(false); + expect(result.hasNext).toBe(false); + }); + + it('should have navigation functions', () => { + const result = usePagination({ currentPage: 1, totalPages: 5 }); + + expect(typeof result.nextPage).toBe('function'); + expect(typeof result.prevPage).toBe('function'); + expect(typeof result.goToPage).toBe('function'); + }); +}); diff --git a/packages/lit-components/src/components/Pagination/usePagination.ts b/packages/lit-components/src/components/Pagination/usePagination.ts new file mode 100644 index 0000000000..3db953aa49 --- /dev/null +++ b/packages/lit-components/src/components/Pagination/usePagination.ts @@ -0,0 +1,70 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { PaginationProps } from './Pagination'; + +export function usePagination(props: PaginationProps) { + const [page, setPage] = useState(Number(props.currentPage) || 1); + const totalPages = Number(props.totalPages) || 1; + + function goToPage(p: number) { + if (p >= 1 && p <= totalPages) { + setPage(p); + const event = new CustomEvent('sq:page-change', { + bubbles: true, + composed: true, + detail: { page: p }, + }); + document.dispatchEvent(event); + } + } + + function nextPage() { + goToPage(page + 1); + } + + function prevPage() { + goToPage(page - 1); + } + + return { + page, + totalPages, + nextPage, + prevPage, + goToPage, + hasPrev: page > 1, + hasNext: page < totalPages, + }; +} + +export function useDemoPagination(_props: PaginationProps): ReturnType { + const [page, setPage] = useState(1); + const totalPages = 5; + + function goToPage(nextPage: number) { + if (nextPage >= 1 && nextPage <= totalPages) { + setPage(nextPage); + } + } + + function nextPage() { + if (page < totalPages) { + setPage(page + 1); + } + } + + function prevPage() { + if (page > 1) { + setPage(page - 1); + } + } + + return { + page, + totalPages, + nextPage, + prevPage, + goToPage, + hasPrev: page > 1, + hasNext: page < totalPages, + }; +} diff --git a/packages/lit-components/src/components/PasswordField/PasswordField.stories.ts b/packages/lit-components/src/components/PasswordField/PasswordField.stories.ts new file mode 100644 index 0000000000..53c7d71bc6 --- /dev/null +++ b/packages/lit-components/src/components/PasswordField/PasswordField.stories.ts @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/PasswordField', + component: 'sql-password-field', + tags: ['autodocs'], + argTypes: { + 'field-label': { control: 'text' }, + 'field-name': { control: 'text' }, + 'field-required': { control: 'boolean' }, + 'field-disabled': { control: 'boolean' }, + 'field-placeholder': { control: 'text' }, + 'field-min-length': { control: 'number' }, + 'field-max-length': { control: 'number' }, + 'field-help-text': { control: 'text' }, + 'field-size': { control: 'select', options: ['small', 'medium', 'large'] }, + 'show-toggle': { control: 'boolean' }, + }, + render: (args) => html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Required: Story = { + args: { + 'field-required': true, + }, +}; + +export const WithMinLength: Story = { + args: { + 'field-min-length': 12, + 'field-help-text': 'Use at least 12 characters for a stronger password.', + }, +}; + +export const WithPlaceholder: Story = { + args: { + 'field-placeholder': 'Enter your password', + }, +}; + +export const Disabled: Story = { + args: { + 'field-disabled': true, + }, +}; + +export const WithToggle: Story = { + args: { + 'show-toggle': true, + }, +}; + +export const WithError: Story = { + args: { + 'field-required': true, + 'field-min-length': 10, + }, +}; diff --git a/packages/lit-components/src/components/PasswordField/PasswordField.ts b/packages/lit-components/src/components/PasswordField/PasswordField.ts new file mode 100644 index 0000000000..0db2ea1416 --- /dev/null +++ b/packages/lit-components/src/components/PasswordField/PasswordField.ts @@ -0,0 +1,73 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PasswordFieldView } from './PasswordFieldView'; +import { usePasswordField, useDemoPasswordField } from './usePasswordField'; + +export interface PasswordFieldProps { + fieldLabel: string; + fieldName: string; + fieldRequired: boolean; + fieldDisabled: boolean; + fieldPlaceholder?: string; + fieldMinLength: number; + fieldMaxLength?: number; + fieldHelpText?: string; + fieldSize: 'small' | 'medium' | 'large'; + showToggle: boolean; +} + +function getBooleanAttribute(host: HTMLElement, name: string, defaultValue = false) { + if (!host.hasAttribute(name)) { + return defaultValue; + } + + const value = host.getAttribute(name); + return value === '' || value === 'true'; +} + +function getNumberAttribute(host: HTMLElement, name: string, defaultValue?: number) { + const value = host.getAttribute(name); + return value !== null && value !== '' ? Number(value) : defaultValue; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-password-field': HTMLElement; + } +} + +export const PasswordField = useComponent( + (host) => { + const rawProps = getProps(host); + const props: PasswordFieldProps = { + fieldLabel: rawProps.fieldLabel || 'Password', + fieldName: rawProps.fieldName || 'password', + fieldRequired: getBooleanAttribute(host, 'field-required', false), + fieldDisabled: getBooleanAttribute(host, 'field-disabled', false), + fieldPlaceholder: rawProps.fieldPlaceholder || undefined, + fieldMinLength: getNumberAttribute(host, 'field-min-length', 8) || 8, + fieldMaxLength: getNumberAttribute(host, 'field-max-length'), + fieldHelpText: rawProps.fieldHelpText || undefined, + fieldSize: (rawProps.fieldSize as PasswordFieldProps['fieldSize']) || 'medium', + showToggle: getBooleanAttribute(host, 'show-toggle', true), + }; + + const hookProps = isDemo() ? useDemoPasswordField(props) : usePasswordField(props); + + return PasswordFieldView({ ...props, ...hookProps }); + }, + 'sql-password-field', + [ + 'field-label', + 'field-name', + 'field-required', + 'field-disabled', + 'field-placeholder', + 'field-min-length', + 'field-max-length', + 'field-help-text', + 'field-size', + 'show-toggle', + ] as const +); diff --git a/packages/lit-components/src/components/PasswordField/PasswordFieldView.ts b/packages/lit-components/src/components/PasswordField/PasswordFieldView.ts new file mode 100644 index 0000000000..236246b671 --- /dev/null +++ b/packages/lit-components/src/components/PasswordField/PasswordFieldView.ts @@ -0,0 +1,41 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PasswordFieldProps } from './PasswordField'; +import { usePasswordField } from './usePasswordField'; + +const styles = ` + :host { + display: block; + } + + sl-input { + width: 100%; + } +`; + +export function PasswordFieldView( + props: PasswordFieldProps & ReturnType +) { + return html` + + ${UI.Input({ + label: props.fieldLabel, + name: props.fieldName, + type: 'password', + placeholder: props.fieldPlaceholder || '', + required: props.fieldRequired, + disabled: props.fieldDisabled, + passwordToggle: props.showToggle, + size: props.fieldSize, + minLength: props.fieldMinLength, + maxLength: props.fieldMaxLength, + value: props.value, + onInput: props.onInput, + onBlur: props.onBlur, + error: props.error, + helpText: props.fieldHelpText, + })} + `; +} diff --git a/packages/lit-components/src/components/PasswordField/index.ts b/packages/lit-components/src/components/PasswordField/index.ts new file mode 100644 index 0000000000..0e2024ef6c --- /dev/null +++ b/packages/lit-components/src/components/PasswordField/index.ts @@ -0,0 +1 @@ +export * from './PasswordField'; diff --git a/packages/lit-components/src/components/PasswordField/usePasswordField.test.ts b/packages/lit-components/src/components/PasswordField/usePasswordField.test.ts new file mode 100644 index 0000000000..4c44e9ee52 --- /dev/null +++ b/packages/lit-components/src/components/PasswordField/usePasswordField.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePasswordField } from './usePasswordField'; + +describe('usePasswordField', () => { + const defaultProps = { + fieldLabel: 'Password', + fieldName: 'password', + fieldRequired: false, + fieldDisabled: false, + fieldMinLength: 8, + fieldMaxLength: undefined, + fieldSize: 'medium', + showToggle: true, + } as const; + + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + }); + + it('should return initial state', () => { + const result = usePasswordField(defaultProps); + + expect(result.value).toBe(''); + expect(result.error).toBe(''); + expect(result.showPassword).toBe(false); + }); + + it('should have togglePassword function', () => { + const result = usePasswordField(defaultProps); + + result.togglePassword(); + + expect(typeof result.togglePassword).toBe('function'); + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith(true); + }); + + it('should have input and blur handlers', () => { + const result = usePasswordField({ ...defaultProps, fieldRequired: true }); + + result.onInput({ target: { value: 'secret' } } as unknown as Event); + result.onBlur(); + + expect(typeof result.onInput).toBe('function'); + expect(typeof result.onBlur).toBe('function'); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith('secret'); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('This field is required'); + }); +}); diff --git a/packages/lit-components/src/components/PasswordField/usePasswordField.ts b/packages/lit-components/src/components/PasswordField/usePasswordField.ts new file mode 100644 index 0000000000..079117ce23 --- /dev/null +++ b/packages/lit-components/src/components/PasswordField/usePasswordField.ts @@ -0,0 +1,61 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { PasswordFieldProps } from './PasswordField'; + +function validatePassword(props: PasswordFieldProps, val: string) { + if (props.fieldRequired && !val) return 'This field is required'; + if (val && props.fieldMinLength && val.length < Number(props.fieldMinLength)) { + return `Password must be at least ${props.fieldMinLength} characters`; + } + if (val && props.fieldMaxLength && val.length > Number(props.fieldMaxLength)) { + return `Password must be no more than ${props.fieldMaxLength} characters`; + } + return ''; +} + +export function usePasswordField(props: PasswordFieldProps) { + const [value, setValue] = useState(''); + const [touched, setTouched] = useState(false); + const [error, setError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + function onInput(e: Event) { + const target = e.target as HTMLInputElement; + setValue(target.value); + if (touched) setError(validatePassword(props, target.value)); + } + + function onBlur() { + setTouched(true); + setError(validatePassword(props, value)); + } + + function togglePassword() { + setShowPassword(!showPassword); + } + + return { value, error, touched, showPassword, onInput, onBlur, togglePassword }; +} + +export function useDemoPasswordField(props: PasswordFieldProps): ReturnType { + const [value, setValue] = useState('Password123!'); + const [touched, setTouched] = useState(false); + const [error, setError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + function onInput(e: Event) { + const target = e.target as HTMLInputElement; + setValue(target.value); + if (touched) setError(validatePassword(props, target.value)); + } + + function onBlur() { + setTouched(true); + setError(validatePassword(props, value)); + } + + function togglePassword() { + setShowPassword(!showPassword); + } + + return { value, error, touched, showPassword, onInput, onBlur, togglePassword }; +} diff --git a/packages/lit-components/src/components/PayoutButtonScroll/PayoutButtonScroll.stories.ts b/packages/lit-components/src/components/PayoutButtonScroll/PayoutButtonScroll.stories.ts new file mode 100644 index 0000000000..0024680468 --- /dev/null +++ b/packages/lit-components/src/components/PayoutButtonScroll/PayoutButtonScroll.stories.ts @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/PayoutButtonScroll', + component: 'sql-payout-button-scroll', + tags: ['autodocs'], + render: (args) => html` +
+ +
+ Scroll target +
+
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + buttonText: 'Cash Out', + disabled: false, + }, +}; + +export const Disabled: Story = { + args: { + buttonText: 'Cash Out', + disabled: true, + }, +}; + +export const CustomText: Story = { + args: { + buttonText: 'Request Payout', + disabled: false, + }, +}; diff --git a/packages/lit-components/src/components/PayoutButtonScroll/PayoutButtonScroll.ts b/packages/lit-components/src/components/PayoutButtonScroll/PayoutButtonScroll.ts new file mode 100644 index 0000000000..294059cf81 --- /dev/null +++ b/packages/lit-components/src/components/PayoutButtonScroll/PayoutButtonScroll.ts @@ -0,0 +1,40 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PayoutButtonScrollView } from './PayoutButtonScrollView'; +import { useDemoPayoutButton, usePayoutButton } from './usePayoutButtonScroll'; + +export interface PayoutButtonScrollProps { + buttonText: string; + scrollTargetId?: string; + disabled: boolean; +} + +const parseBoolean = (value: unknown, fallback: boolean) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-payout-button-scroll': HTMLElement; + } +} + +export const PayoutButtonScroll = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PayoutButtonScrollProps = { + buttonText: typeof rawProps.buttonText === 'string' ? rawProps.buttonText : 'Cash Out', + scrollTargetId: typeof rawProps.scrollTargetId === 'string' ? rawProps.scrollTargetId : undefined, + disabled: parseBoolean(rawProps.disabled, false), + }; + + const hookProps = isDemo() ? useDemoPayoutButton(props) : usePayoutButton(props); + + return PayoutButtonScrollView({ ...props, ...hookProps }); + }, + 'sql-payout-button-scroll', + ['button-text', 'scroll-target-id', 'disabled'] as const +); diff --git a/packages/lit-components/src/components/PayoutButtonScroll/PayoutButtonScrollView.ts b/packages/lit-components/src/components/PayoutButtonScroll/PayoutButtonScrollView.ts new file mode 100644 index 0000000000..cf2aa40c50 --- /dev/null +++ b/packages/lit-components/src/components/PayoutButtonScroll/PayoutButtonScrollView.ts @@ -0,0 +1,21 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PayoutButtonScrollProps } from './PayoutButtonScroll'; +import { usePayoutButton } from './usePayoutButtonScroll'; + +export function PayoutButtonScrollView( + props: PayoutButtonScrollProps & ReturnType +) { + return html` + + ${UI.Button({ + variant: 'primary', + disabled: props.disabled, + onClick: props.onClick, + prefix: UI.Icon({ name: 'cash-stack' }), + children: props.buttonText, + })} + `; +} diff --git a/packages/lit-components/src/components/PayoutButtonScroll/index.ts b/packages/lit-components/src/components/PayoutButtonScroll/index.ts new file mode 100644 index 0000000000..29b0345a61 --- /dev/null +++ b/packages/lit-components/src/components/PayoutButtonScroll/index.ts @@ -0,0 +1 @@ +export * from './PayoutButtonScroll'; diff --git a/packages/lit-components/src/components/PayoutButtonScroll/usePayoutButtonScroll.test.ts b/packages/lit-components/src/components/PayoutButtonScroll/usePayoutButtonScroll.test.ts new file mode 100644 index 0000000000..67dcea8431 --- /dev/null +++ b/packages/lit-components/src/components/PayoutButtonScroll/usePayoutButtonScroll.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { usePayoutButton } from './usePayoutButtonScroll'; + +describe('usePayoutButton', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.stubGlobal('document', { + dispatchEvent: vi.fn(), + getElementById: vi.fn(() => null), + }); + }); + + it('should return an onClick handler', () => { + const result = usePayoutButton({ buttonText: 'Cash Out', disabled: false }); + + expect(typeof result.onClick).toBe('function'); + }); + + it('should dispatch a payout click event on click', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePayoutButton({ buttonText: 'Cash Out', disabled: false }); + + result.onClick(); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:payout-click'); + expect(event.bubbles).toBe(true); + expect(event.composed).toBe(true); + }); + + it('should scroll to the target when scrollTargetId is provided', () => { + const target = { scrollIntoView: vi.fn() }; + vi.mocked(document.getElementById).mockReturnValue(target as never); + + const result = usePayoutButton({ + buttonText: 'Cash Out', + disabled: false, + scrollTargetId: 'payout-target', + }); + + result.onClick(); + + expect(document.getElementById).toHaveBeenCalledWith('payout-target'); + expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }); + }); +}); diff --git a/packages/lit-components/src/components/PayoutButtonScroll/usePayoutButtonScroll.ts b/packages/lit-components/src/components/PayoutButtonScroll/usePayoutButtonScroll.ts new file mode 100644 index 0000000000..4bf0e653fe --- /dev/null +++ b/packages/lit-components/src/components/PayoutButtonScroll/usePayoutButtonScroll.ts @@ -0,0 +1,26 @@ +import { PayoutButtonScrollProps } from './PayoutButtonScroll'; + +export function usePayoutButton(props: PayoutButtonScrollProps) { + function onClick() { + if (props.scrollTargetId) { + const target = document.getElementById(props.scrollTargetId); + if (target) { + target.scrollIntoView({ behavior: 'smooth' }); + } + } + + const event = new CustomEvent('sq:payout-click', { + bubbles: true, + composed: true, + }); + document.dispatchEvent(event); + } + + return { onClick }; +} + +export function useDemoPayoutButton(_props: PayoutButtonScrollProps): ReturnType { + return { + onClick: () => undefined, + }; +} diff --git a/packages/lit-components/src/components/PayoutDetailsCard/PayoutDetailsCard.stories.ts b/packages/lit-components/src/components/PayoutDetailsCard/PayoutDetailsCard.stories.ts new file mode 100644 index 0000000000..82096445b1 --- /dev/null +++ b/packages/lit-components/src/components/PayoutDetailsCard/PayoutDetailsCard.stories.ts @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { PayoutDetailsCardView } from './PayoutDetailsCardView'; + +const meta: Meta = { + title: 'Components/PayoutDetailsCard', + component: 'sql-payout-details-card', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Payout Details', + nextPayoutLabel: 'Next Payout', + balanceLabel: 'Available Balance', + programId: 'demo-program', + loading: false, + nextPayoutDate: '2025-01-15', + availableBalance: '$1,250.00', + currency: 'USD', + payoutMethod: 'ACH', +}; + +export const Default: Story = { + render: () => PayoutDetailsCardView(baseProps), +}; + +export const Loading: Story = { + render: () => + PayoutDetailsCardView({ + ...baseProps, + loading: true, + }), +}; + +export const WithData: Story = { + render: () => + PayoutDetailsCardView({ + ...baseProps, + availableBalance: '$2,340.55', + nextPayoutDate: '2025-02-01', + payoutMethod: 'Wire', + }), +}; diff --git a/packages/lit-components/src/components/PayoutDetailsCard/PayoutDetailsCard.ts b/packages/lit-components/src/components/PayoutDetailsCard/PayoutDetailsCard.ts new file mode 100644 index 0000000000..65ce8b5803 --- /dev/null +++ b/packages/lit-components/src/components/PayoutDetailsCard/PayoutDetailsCard.ts @@ -0,0 +1,36 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PayoutDetailsCardView } from './PayoutDetailsCardView'; +import { useDemoPayoutDetailsCard, usePayoutDetailsCard } from './usePayoutDetailsCard'; + +export interface PayoutDetailsCardProps { + headerText: string; + nextPayoutLabel: string; + balanceLabel: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-payout-details-card': HTMLElement; + } +} + +export const PayoutDetailsCard = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PayoutDetailsCardProps = { + headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Payout Details', + nextPayoutLabel: typeof rawProps.nextPayoutLabel === 'string' ? rawProps.nextPayoutLabel : 'Next Payout', + balanceLabel: typeof rawProps.balanceLabel === 'string' ? rawProps.balanceLabel : 'Available Balance', + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + }; + + const hookProps = isDemo() ? useDemoPayoutDetailsCard(props) : usePayoutDetailsCard(props); + + return PayoutDetailsCardView({ ...props, ...hookProps }); + }, + 'sql-payout-details-card', + ['header-text', 'next-payout-label', 'balance-label', 'program-id'] as const +); diff --git a/packages/lit-components/src/components/PayoutDetailsCard/PayoutDetailsCardView.ts b/packages/lit-components/src/components/PayoutDetailsCard/PayoutDetailsCardView.ts new file mode 100644 index 0000000000..e42257d3a4 --- /dev/null +++ b/packages/lit-components/src/components/PayoutDetailsCard/PayoutDetailsCardView.ts @@ -0,0 +1,59 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PayoutDetailsCardProps } from './PayoutDetailsCard'; +import { usePayoutDetailsCard } from './usePayoutDetailsCard'; + +export function PayoutDetailsCardView(props: PayoutDetailsCardProps & ReturnType) { + return html` + +
+

${props.headerText}

+ ${props.loading + ? html`${UI.Spinner({})}` + : html` +
+
+
${props.balanceLabel}
+
${props.availableBalance}
+
+
+
${props.nextPayoutLabel}
+
${props.nextPayoutDate}
+
+
+ `} +
+ `; +} diff --git a/packages/lit-components/src/components/PayoutDetailsCard/index.ts b/packages/lit-components/src/components/PayoutDetailsCard/index.ts new file mode 100644 index 0000000000..81ef92bac2 --- /dev/null +++ b/packages/lit-components/src/components/PayoutDetailsCard/index.ts @@ -0,0 +1 @@ +export * from './PayoutDetailsCard'; diff --git a/packages/lit-components/src/components/PayoutDetailsCard/usePayoutDetailsCard.test.ts b/packages/lit-components/src/components/PayoutDetailsCard/usePayoutDetailsCard.test.ts new file mode 100644 index 0000000000..bd27b2f46a --- /dev/null +++ b/packages/lit-components/src/components/PayoutDetailsCard/usePayoutDetailsCard.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { + viewer: { + payoutDetails: { + nextPayoutDate: '2025-01-31', + availableBalance: '$42.00', + currency: 'CAD', + payoutMethod: 'PayPal', + }, + }, + }, + loading: false, + })), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { usePayoutDetailsCard } from './usePayoutDetailsCard'; + +describe('usePayoutDetailsCard', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockClear(); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + viewer: { + payoutDetails: { + nextPayoutDate: '2025-01-31', + availableBalance: '$42.00', + currency: 'CAD', + payoutMethod: 'PayPal', + }, + }, + }, + loading: false, + }); + }); + + it('returns payout details from query data', () => { + const result = usePayoutDetailsCard({} as any); + + expect(result.nextPayoutDate).toBe('2025-01-31'); + expect(result.availableBalance).toBe('$42.00'); + expect(result.currency).toBe('CAD'); + expect(result.payoutMethod).toBe('PayPal'); + }); + + it('returns default values when data is missing', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: { viewer: {} }, loading: false }); + + const result = usePayoutDetailsCard({} as any); + + expect(result.nextPayoutDate).toBe('N/A'); + expect(result.availableBalance).toBe('$0.00'); + expect(result.currency).toBe('USD'); + expect(result.payoutMethod).toBe('Not configured'); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = usePayoutDetailsCard({} as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/PayoutDetailsCard/usePayoutDetailsCard.ts b/packages/lit-components/src/components/PayoutDetailsCard/usePayoutDetailsCard.ts new file mode 100644 index 0000000000..60b0828537 --- /dev/null +++ b/packages/lit-components/src/components/PayoutDetailsCard/usePayoutDetailsCard.ts @@ -0,0 +1,46 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { gql } from 'graphql-request'; +import { PayoutDetailsCardProps } from './PayoutDetailsCard'; + +const PAYOUT_QUERY = gql` + query getPayoutDetails($programId: ID) { + viewer { + ... on User { + payoutDetails(programId: $programId) { + nextPayoutDate + availableBalance + currency + payoutMethod + } + } + } + } +`; + +export function usePayoutDetailsCard(props: PayoutDetailsCardProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const { data, loading } = useQuery(PAYOUT_QUERY, { programId }, !user?.jwt); + + const details = data?.viewer?.payoutDetails; + + return { + loading, + nextPayoutDate: details?.nextPayoutDate || 'N/A', + availableBalance: details?.availableBalance || '$0.00', + currency: details?.currency || 'USD', + payoutMethod: details?.payoutMethod || 'Not configured', + }; +} + +export function useDemoPayoutDetailsCard( + _props: PayoutDetailsCardProps +): ReturnType { + return { + loading: false, + nextPayoutDate: 'March 14, 2024', + availableBalance: '$100.00', + currency: 'USD', + payoutMethod: 'Bank transfer', + }; +} diff --git a/packages/lit-components/src/components/PayoutStatusAlert/PayoutStatusAlert.stories.ts b/packages/lit-components/src/components/PayoutStatusAlert/PayoutStatusAlert.stories.ts new file mode 100644 index 0000000000..0d8f62e427 --- /dev/null +++ b/packages/lit-components/src/components/PayoutStatusAlert/PayoutStatusAlert.stories.ts @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { PayoutStatusAlertView } from './PayoutStatusAlertView'; + +const meta: Meta = { + title: 'Components/PayoutStatusAlert', + component: 'sql-payout-status-alert', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + pendingText: 'Your payout is being processed.', + approvedText: 'Your payout has been approved!', + deniedText: 'Your payout was denied. Please contact support.', + programId: 'demo-program', + status: '', + loading: false, + variant: 'neutral', + text: '', +}; + +export const Pending: Story = { + render: () => + PayoutStatusAlertView({ + ...baseProps, + status: 'PENDING', + variant: 'warning', + text: baseProps.pendingText, + }), +}; + +export const Approved: Story = { + render: () => + PayoutStatusAlertView({ + ...baseProps, + status: 'APPROVED', + variant: 'success', + text: baseProps.approvedText, + }), +}; + +export const Denied: Story = { + render: () => + PayoutStatusAlertView({ + ...baseProps, + status: 'DENIED', + variant: 'danger', + text: baseProps.deniedText, + }), +}; diff --git a/packages/lit-components/src/components/PayoutStatusAlert/PayoutStatusAlert.ts b/packages/lit-components/src/components/PayoutStatusAlert/PayoutStatusAlert.ts new file mode 100644 index 0000000000..02288d3093 --- /dev/null +++ b/packages/lit-components/src/components/PayoutStatusAlert/PayoutStatusAlert.ts @@ -0,0 +1,41 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PayoutStatusAlertView } from './PayoutStatusAlertView'; +import { useDemoPayoutStatus, usePayoutStatus } from './usePayoutStatus'; + +export interface PayoutStatusAlertProps { + pendingText: string; + approvedText: string; + deniedText: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-payout-status-alert': HTMLElement; + } +} + +export const PayoutStatusAlert = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PayoutStatusAlertProps = { + pendingText: + typeof rawProps.pendingText === 'string' ? rawProps.pendingText : 'Your payout is being processed.', + approvedText: + typeof rawProps.approvedText === 'string' ? rawProps.approvedText : 'Your payout has been approved!', + deniedText: + typeof rawProps.deniedText === 'string' + ? rawProps.deniedText + : 'Your payout was denied. Please contact support.', + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + }; + + const hookProps = isDemo() ? useDemoPayoutStatus(props) : usePayoutStatus(props); + + return PayoutStatusAlertView({ ...props, ...hookProps }); + }, + 'sql-payout-status-alert', + ['pending-text', 'approved-text', 'denied-text', 'program-id'] as const +); diff --git a/packages/lit-components/src/components/PayoutStatusAlert/PayoutStatusAlertView.ts b/packages/lit-components/src/components/PayoutStatusAlert/PayoutStatusAlertView.ts new file mode 100644 index 0000000000..55738080c7 --- /dev/null +++ b/packages/lit-components/src/components/PayoutStatusAlert/PayoutStatusAlertView.ts @@ -0,0 +1,33 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { AlertVariant } from '../../ui/types'; +import { PayoutStatusAlertProps } from './PayoutStatusAlert'; +import { usePayoutStatus } from './usePayoutStatus'; + +export function PayoutStatusAlertView(props: PayoutStatusAlertProps & ReturnType) { + const iconName = + props.variant === 'success' + ? 'check-circle' + : props.variant === 'danger' + ? 'exclamation-circle' + : 'clock'; + + return html` + + ${props.loading + ? html`` + : props.text + ? html` + ${UI.Alert({ + variant: props.variant as AlertVariant, + open: true, + icon: UI.Icon({ name: iconName }), + children: props.text, + })}` + : html``} + `; +} diff --git a/packages/lit-components/src/components/PayoutStatusAlert/index.ts b/packages/lit-components/src/components/PayoutStatusAlert/index.ts new file mode 100644 index 0000000000..eb2e9ba2cb --- /dev/null +++ b/packages/lit-components/src/components/PayoutStatusAlert/index.ts @@ -0,0 +1 @@ +export * from './PayoutStatusAlert'; diff --git a/packages/lit-components/src/components/PayoutStatusAlert/usePayoutStatus.test.ts b/packages/lit-components/src/components/PayoutStatusAlert/usePayoutStatus.test.ts new file mode 100644 index 0000000000..15a8a7b19a --- /dev/null +++ b/packages/lit-components/src/components/PayoutStatusAlert/usePayoutStatus.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { viewer: { payoutDetails: { payoutStatus: 'APPROVED' } } }, + loading: false, + })), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { usePayoutStatus } from './usePayoutStatus'; + +describe('usePayoutStatus', () => { + const props = { + approvedText: 'Approved text', + deniedText: 'Denied text', + pendingText: 'Pending text', + }; + + beforeEach(() => { + componentBoilerplateMock.useQuery.mockClear(); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { payoutDetails: { payoutStatus: 'APPROVED' } } }, + loading: false, + }); + }); + + it('returns success variant and approved text for approved status', () => { + const result = usePayoutStatus(props as any); + + expect(result.status).toBe('APPROVED'); + expect(result.variant).toBe('success'); + expect(result.text).toBe('Approved text'); + }); + + it('returns danger variant and denied text for denied status', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { payoutDetails: { payoutStatus: 'DENIED' } } }, + loading: false, + }); + + const result = usePayoutStatus(props as any); + + expect(result.variant).toBe('danger'); + expect(result.text).toBe('Denied text'); + }); + + it('returns warning variant and pending text for pending status', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { payoutDetails: { payoutStatus: 'PENDING' } } }, + loading: false, + }); + + const result = usePayoutStatus(props as any); + + expect(result.variant).toBe('warning'); + expect(result.text).toBe('Pending text'); + }); + + it('returns neutral variant and empty text for unknown status', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { payoutDetails: { payoutStatus: 'OTHER' } } }, + loading: false, + }); + + const result = usePayoutStatus(props as any); + + expect(result.variant).toBe('neutral'); + expect(result.text).toBe(''); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { payoutDetails: { payoutStatus: 'APPROVED' } } }, + loading: true, + }); + + const result = usePayoutStatus(props as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/PayoutStatusAlert/usePayoutStatus.ts b/packages/lit-components/src/components/PayoutStatusAlert/usePayoutStatus.ts new file mode 100644 index 0000000000..fa7365d8b8 --- /dev/null +++ b/packages/lit-components/src/components/PayoutStatusAlert/usePayoutStatus.ts @@ -0,0 +1,62 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { gql } from 'graphql-request'; +import { PayoutStatusAlertProps } from './PayoutStatusAlert'; + +const PAYOUT_STATUS_QUERY = gql` + query getPayoutStatus($programId: ID) { + viewer { + ... on User { + payoutDetails(programId: $programId) { + payoutStatus + } + } + } + } +`; + +export function usePayoutStatus(props: PayoutStatusAlertProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const { data, loading } = useQuery(PAYOUT_STATUS_QUERY, { programId }, !user?.jwt); + + const status = data?.viewer?.payoutDetails?.payoutStatus || ''; + + function getVariant() { + switch (status) { + case 'APPROVED': + return 'success'; + case 'DENIED': + return 'danger'; + case 'PENDING': + return 'warning'; + default: + return 'neutral'; + } + } + + function getText() { + switch (status) { + case 'APPROVED': + return props.approvedText; + case 'DENIED': + return props.deniedText; + case 'PENDING': + return props.pendingText; + default: + return ''; + } + } + + return { status, loading, variant: getVariant(), text: getText() }; +} + +export function useDemoPayoutStatus( + props: PayoutStatusAlertProps +): ReturnType { + return { + status: 'PENDING', + loading: false, + variant: 'warning', + text: props.pendingText, + }; +} diff --git a/packages/lit-components/src/components/PopupContainer/PopupContainer.stories.ts b/packages/lit-components/src/components/PopupContainer/PopupContainer.stories.ts new file mode 100644 index 0000000000..e95ebfc8c8 --- /dev/null +++ b/packages/lit-components/src/components/PopupContainer/PopupContainer.stories.ts @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/PopupContainer', + component: 'sql-popup-container', + tags: ['autodocs'], + argTypes: { + 'popup-title': { control: 'text' }, + 'show-close-button': { control: 'boolean' }, + 'overlay-color': { control: 'text' }, + 'max-width': { control: 'text' }, + padding: { control: 'text' }, + }, + render: (args) => html` + +

This is popup content rendered inside the container.

+

You can place any slotted content here.

+
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const WithTitle: Story = { + args: { + 'popup-title': 'Rewards Details', + }, +}; + +export const NoCloseButton: Story = { + args: { + 'popup-title': 'Popup Without Close Button', + 'show-close-button': false, + }, +}; + +export const CustomWidth: Story = { + args: { + 'popup-title': 'Wide Popup', + 'max-width': '800px', + }, +}; + +export const CustomOverlay: Story = { + args: { + 'popup-title': 'Custom Overlay', + 'overlay-color': 'rgba(99, 102, 241, 0.35)', + }, +}; diff --git a/packages/lit-components/src/components/PopupContainer/PopupContainer.ts b/packages/lit-components/src/components/PopupContainer/PopupContainer.ts new file mode 100644 index 0000000000..060adc7c85 --- /dev/null +++ b/packages/lit-components/src/components/PopupContainer/PopupContainer.ts @@ -0,0 +1,44 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PopupContainerView } from './PopupContainerView'; +import { usePopupContainer } from './usePopupContainer'; + +export interface PopupContainerProps { + popupTitle?: string; + showCloseButton?: boolean; + overlayColor?: string; + maxWidth?: string; + padding?: string; +} + +const parseBoolean = (value: unknown, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-popup-container': HTMLElement; + } +} + +export const PopupContainer = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PopupContainerProps = { + popupTitle: typeof rawProps.popupTitle === 'string' ? rawProps.popupTitle : undefined, + showCloseButton: parseBoolean(rawProps.showCloseButton, true), + overlayColor: + typeof rawProps.overlayColor === 'string' ? rawProps.overlayColor : 'rgba(0, 0, 0, 0.5)', + maxWidth: typeof rawProps.maxWidth === 'string' ? rawProps.maxWidth : '600px', + padding: typeof rawProps.padding === 'string' ? rawProps.padding : 'var(--sl-spacing-large)', + }; + + const hookProps = usePopupContainer(props); + + return PopupContainerView({ ...props, ...hookProps }); + }, + 'sql-popup-container', + ['popup-title', 'show-close-button', 'overlay-color', 'max-width', 'padding'] as const +); diff --git a/packages/lit-components/src/components/PopupContainer/PopupContainerView.ts b/packages/lit-components/src/components/PopupContainer/PopupContainerView.ts new file mode 100644 index 0000000000..ae8fb0766a --- /dev/null +++ b/packages/lit-components/src/components/PopupContainer/PopupContainerView.ts @@ -0,0 +1,72 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PopupContainerProps } from './PopupContainer'; +import { usePopupContainer } from './usePopupContainer'; + +export function PopupContainerView(props: PopupContainerProps & ReturnType) { + const hookProps = props; + + return html` + +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/PopupContainer/index.ts b/packages/lit-components/src/components/PopupContainer/index.ts new file mode 100644 index 0000000000..5000badede --- /dev/null +++ b/packages/lit-components/src/components/PopupContainer/index.ts @@ -0,0 +1 @@ +export * from './PopupContainer'; diff --git a/packages/lit-components/src/components/PopupContainer/usePopupContainer.test.ts b/packages/lit-components/src/components/PopupContainer/usePopupContainer.test.ts new file mode 100644 index 0000000000..ae88dbcab3 --- /dev/null +++ b/packages/lit-components/src/components/PopupContainer/usePopupContainer.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePopupContainer } from './usePopupContainer'; + +describe('usePopupContainer', () => { + beforeEach(() => { + universalHooksMock.useState.mockClear(); + }); + + it('should start open by default', () => { + const result = usePopupContainer({}); + + expect(result.isOpen).toBe(true); + }); + + it('should have close function', () => { + const result = usePopupContainer({}); + + expect(typeof result.close).toBe('function'); + }); +}); diff --git a/packages/lit-components/src/components/PopupContainer/usePopupContainer.ts b/packages/lit-components/src/components/PopupContainer/usePopupContainer.ts new file mode 100644 index 0000000000..49770f4026 --- /dev/null +++ b/packages/lit-components/src/components/PopupContainer/usePopupContainer.ts @@ -0,0 +1,14 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { PopupContainerProps } from './PopupContainer'; + +export function usePopupContainer(_props: PopupContainerProps) { + const [isOpen, setIsOpen] = useState(true); + + function close() { + setIsOpen(false); + const event = new CustomEvent('sq:popup-close', { bubbles: true, composed: true }); + document.dispatchEvent(event); + } + + return { isOpen, close }; +} diff --git a/packages/lit-components/src/components/PortalChangeMarketing/PortalChangeMarketing.stories.ts b/packages/lit-components/src/components/PortalChangeMarketing/PortalChangeMarketing.stories.ts new file mode 100644 index 0000000000..4c3ea06a4f --- /dev/null +++ b/packages/lit-components/src/components/PortalChangeMarketing/PortalChangeMarketing.stories.ts @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { PortalChangeMarketingView } from './PortalChangeMarketingView'; + +const meta: Meta = { + title: 'Components/PortalChangeMarketing', + component: 'sql-portal-change-marketing', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Email Preferences', + description: 'Manage your email notification preferences.', + subscribedText: 'You are subscribed to marketing emails.', + unsubscribedText: 'You are not subscribed to marketing emails.', + subscribeLabel: 'Subscribe', + unsubscribeLabel: 'Unsubscribe', + saving: false, + onToggle: async () => {}, +}; + +export const Default: Story = { + render: () => PortalChangeMarketingView({ ...baseProps, subscribed: true, loading: false }), +}; + +export const Subscribed: Story = { + render: () => PortalChangeMarketingView({ ...baseProps, subscribed: true, loading: false }), +}; + +export const Unsubscribed: Story = { + render: () => PortalChangeMarketingView({ ...baseProps, subscribed: false, loading: false }), +}; + +export const Loading: Story = { + render: () => PortalChangeMarketingView({ ...baseProps, subscribed: true, loading: true }), +}; diff --git a/packages/lit-components/src/components/PortalChangeMarketing/PortalChangeMarketing.ts b/packages/lit-components/src/components/PortalChangeMarketing/PortalChangeMarketing.ts new file mode 100644 index 0000000000..9423a04616 --- /dev/null +++ b/packages/lit-components/src/components/PortalChangeMarketing/PortalChangeMarketing.ts @@ -0,0 +1,49 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalChangeMarketingView } from './PortalChangeMarketingView'; +import { useDemoPortalChangeMarketing, usePortalChangeMarketing } from './usePortalChangeMarketing'; + +export interface PortalChangeMarketingProps { + headerText: string; + description: string; + subscribedText: string; + unsubscribedText: string; + subscribeLabel: string; + unsubscribeLabel: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-change-marketing': HTMLElement; + } +} + +export const PortalChangeMarketing = useComponent( + (host) => { + const rawProps = getProps(host); + const props: PortalChangeMarketingProps = { + headerText: rawProps.headerText || 'Email Preferences', + description: rawProps.description || 'Manage your email notification preferences.', + subscribedText: + rawProps.subscribedText || 'You are subscribed to marketing emails.', + unsubscribedText: + rawProps.unsubscribedText || 'You are not subscribed to marketing emails.', + subscribeLabel: rawProps.subscribeLabel || 'Subscribe', + unsubscribeLabel: rawProps.unsubscribeLabel || 'Unsubscribe', + }; + + const hookProps = isDemo() ? useDemoPortalChangeMarketing(props) : usePortalChangeMarketing(props); + + return PortalChangeMarketingView({ ...props, ...hookProps }); + }, + 'sql-portal-change-marketing', + [ + 'header-text', + 'description', + 'subscribed-text', + 'unsubscribed-text', + 'subscribe-label', + 'unsubscribe-label', + ] as const +); diff --git a/packages/lit-components/src/components/PortalChangeMarketing/PortalChangeMarketingView.ts b/packages/lit-components/src/components/PortalChangeMarketing/PortalChangeMarketingView.ts new file mode 100644 index 0000000000..f3934cc51a --- /dev/null +++ b/packages/lit-components/src/components/PortalChangeMarketing/PortalChangeMarketingView.ts @@ -0,0 +1,45 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { PortalChangeMarketingProps } from './PortalChangeMarketing'; +import { usePortalChangeMarketing } from './usePortalChangeMarketing'; + +const styles = ` + :host { + display: block; + } + + .marketing-container { + display: flex; + flex-direction: column; + gap: var(--sl-spacing-medium); + padding: var(--sl-spacing-x-large); + } + + h2, + p { + margin: 0; + } +`; + +export function PortalChangeMarketingView( + props: PortalChangeMarketingProps & ReturnType +) { + return html` + +
+

${props.headerText}

+

${props.description}

+ ${props.loading + ? html`${UI.Spinner({ style: 'font-size: 1.5rem;' })}` + : html`

${props.subscribed ? props.subscribedText : props.unsubscribedText}

`} + ${UI.Button({ + disabled: props.loading || props.saving, + onClick: () => props.onToggle(), + prefix: props.saving ? UI.Spinner({ style: 'font-size: 1rem;' }) : undefined, + children: props.subscribed ? props.unsubscribeLabel : props.subscribeLabel, + })} +
+ `; +} diff --git a/packages/lit-components/src/components/PortalChangeMarketing/index.ts b/packages/lit-components/src/components/PortalChangeMarketing/index.ts new file mode 100644 index 0000000000..75641752f8 --- /dev/null +++ b/packages/lit-components/src/components/PortalChangeMarketing/index.ts @@ -0,0 +1 @@ +export * from './PortalChangeMarketing'; diff --git a/packages/lit-components/src/components/PortalChangeMarketing/usePortalChangeMarketing.test.ts b/packages/lit-components/src/components/PortalChangeMarketing/usePortalChangeMarketing.test.ts new file mode 100644 index 0000000000..49e039cca7 --- /dev/null +++ b/packages/lit-components/src/components/PortalChangeMarketing/usePortalChangeMarketing.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { viewer: { customFields: { marketingOptIn: false } } }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => ({ + setters: [] as Array>, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + universalHooksMock.setters.push(setter); + return [initial, setter] as const; + }), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePortalChangeMarketing } from './usePortalChangeMarketing'; + +describe('usePortalChangeMarketing', () => { + beforeEach(() => { + if (!globalThis.document) { + Object.defineProperty(globalThis, 'document', { + value: { dispatchEvent: () => true }, + configurable: true, + writable: true, + }); + } + if (!globalThis.CustomEvent) { + Object.defineProperty(globalThis, 'CustomEvent', { + value: class { + type: string; + detail: unknown; + constructor(type: string, init?: { detail?: unknown }) { + this.type = type; + this.detail = init?.detail; + } + }, + configurable: true, + }); + } + universalHooksMock.setters.length = 0; + componentBoilerplateMock.useQuery.mockClear(); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { customFields: { marketingOptIn: false } } }, + loading: false, + }); + }); + + it('returns the initial subscribed state from query data', () => { + const result = usePortalChangeMarketing({} as any); + + expect(result.subscribed).toBe(false); + }); + + it('dispatches a marketing change event when toggled', async () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent').mockReturnValue(true); + const result = usePortalChangeMarketing({} as any); + + await result.onToggle(); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0]?.[0] as any; + expect(event.type).toBe('sq:marketing-change'); + expect(event.detail).toEqual({ subscribed: true }); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = usePortalChangeMarketing({} as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/PortalChangeMarketing/usePortalChangeMarketing.ts b/packages/lit-components/src/components/PortalChangeMarketing/usePortalChangeMarketing.ts new file mode 100644 index 0000000000..d1978d73d2 --- /dev/null +++ b/packages/lit-components/src/components/PortalChangeMarketing/usePortalChangeMarketing.ts @@ -0,0 +1,51 @@ +import { useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import type { PortalChangeMarketingProps } from './PortalChangeMarketing'; + +const MARKETING_QUERY = gql` + query getMarketingPrefs { + viewer { + ... on User { + customFields + } + } + } +`; + +export function usePortalChangeMarketing(_props: PortalChangeMarketingProps) { + const user = useUserIdentity(); + const { data, loading } = useQuery(MARKETING_QUERY, {}, !user?.jwt); + const [subscribed, setSubscribed] = useState( + data?.viewer?.customFields?.marketingOptIn ?? true + ); + const [saving, setSaving] = useState(false); + + async function onToggle() { + setSaving(true); + const newValue = !subscribed; + setSubscribed(newValue); + const event = new CustomEvent('sq:marketing-change', { + bubbles: true, + composed: true, + detail: { subscribed: newValue }, + }); + document.dispatchEvent(event); + setSaving(false); + } + + return { subscribed, loading, saving, onToggle }; +} + +export function useDemoPortalChangeMarketing(_props: PortalChangeMarketingProps): ReturnType { + const [subscribed, setSubscribed] = useState(true); + const [saving, setSaving] = useState(false); + + async function onToggle() { + setSaving(true); + setSubscribed(!subscribed); + setSaving(false); + } + + return { subscribed, loading: false, saving, onToggle }; +} diff --git a/packages/lit-components/src/components/PortalChangePassword/PortalChangePassword.stories.ts b/packages/lit-components/src/components/PortalChangePassword/PortalChangePassword.stories.ts new file mode 100644 index 0000000000..e042cf7047 --- /dev/null +++ b/packages/lit-components/src/components/PortalChangePassword/PortalChangePassword.stories.ts @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { PortalChangePasswordView } from './PortalChangePasswordView'; + +const meta: Meta = { + title: 'Components/PortalChangePassword', + component: 'sql-portal-change-password', + tags: ['autodocs'], + argTypes: { + 'current-password-label': { control: 'text' }, + 'new-password-label': { control: 'text' }, + 'confirm-password-label': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'header-text': { control: 'text' }, + 'success-message': { control: 'text' }, + 'password-min-length': { control: 'number' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + currentPasswordLabel: 'Current Password', + newPasswordLabel: 'New Password', + confirmPasswordLabel: 'Confirm New Password', + submitLabel: 'Change Password', + headerText: 'Change Password', + successMessage: 'Password changed successfully.', + passwordMinLength: 8, + currentPassword: 'old-password', + setCurrentPassword: () => undefined, + newPassword: 'new-password-123', + setNewPassword: () => undefined, + confirmPassword: 'new-password-123', + setConfirmPassword: () => undefined, + error: '', + loading: false, + success: false, + isOpen: false, + open: () => undefined, + close: () => undefined, + onSubmit: (event: Event) => event.preventDefault(), +}; + +export const Default: Story = {}; + +export const Open: Story = { + render: () => + PortalChangePasswordView({ + ...baseProps, + isOpen: true, + }), +}; + +export const WithError: Story = { + render: () => + PortalChangePasswordView({ + ...baseProps, + isOpen: true, + error: 'Passwords do not match', + confirmPassword: 'different-password', + }), +}; + +export const Success: Story = { + render: () => + PortalChangePasswordView({ + ...baseProps, + isOpen: true, + success: true, + }), +}; diff --git a/packages/lit-components/src/components/PortalChangePassword/PortalChangePassword.ts b/packages/lit-components/src/components/PortalChangePassword/PortalChangePassword.ts new file mode 100644 index 0000000000..0a559af1db --- /dev/null +++ b/packages/lit-components/src/components/PortalChangePassword/PortalChangePassword.ts @@ -0,0 +1,59 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalChangePasswordView } from './PortalChangePasswordView'; +import { useDemoPortalChangePassword, usePortalChangePassword } from './usePortalChangePassword'; + +export interface PortalChangePasswordProps { + currentPasswordLabel: string; + newPasswordLabel: string; + confirmPasswordLabel: string; + submitLabel: string; + headerText: string; + successMessage: string; + passwordMinLength: number; +} + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim() !== '') return Number(value); + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-change-password': HTMLElement; + } +} + +export const PortalChangePassword = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PortalChangePasswordProps = { + currentPasswordLabel: + typeof rawProps.currentPasswordLabel === 'string' ? rawProps.currentPasswordLabel : 'Current Password', + newPasswordLabel: typeof rawProps.newPasswordLabel === 'string' ? rawProps.newPasswordLabel : 'New Password', + confirmPasswordLabel: + typeof rawProps.confirmPasswordLabel === 'string' ? rawProps.confirmPasswordLabel : 'Confirm New Password', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Change Password', + headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Change Password', + successMessage: + typeof rawProps.successMessage === 'string' ? rawProps.successMessage : 'Password changed successfully.', + passwordMinLength: parseNumber(rawProps.passwordMinLength, 8), + }; + + const hookProps = isDemo() ? useDemoPortalChangePassword(props) : usePortalChangePassword(props); + + return PortalChangePasswordView({ ...props, ...hookProps }); + }, + 'sql-portal-change-password', + [ + 'current-password-label', + 'new-password-label', + 'confirm-password-label', + 'submit-label', + 'header-text', + 'success-message', + 'password-min-length', + ] as const +); diff --git a/packages/lit-components/src/components/PortalChangePassword/PortalChangePasswordView.ts b/packages/lit-components/src/components/PortalChangePassword/PortalChangePasswordView.ts new file mode 100644 index 0000000000..3894344d2b --- /dev/null +++ b/packages/lit-components/src/components/PortalChangePassword/PortalChangePasswordView.ts @@ -0,0 +1,69 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PortalChangePasswordProps } from './PortalChangePassword'; +import { usePortalChangePassword } from './usePortalChangePassword'; + +const getInputValue = (event: Event) => ((event.target as HTMLInputElement & { value: string })?.value ?? ''); + +export function PortalChangePasswordView(props: PortalChangePasswordProps & ReturnType) { + return html` + + ${UI.Button({ variant: 'primary', onClick: props.open, children: props.headerText })} + ${UI.Dialog({ + label: props.headerText, + open: props.isOpen, + onRequestClose: props.close, + onAfterHide: props.close, + children: html` + ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} + ${props.success ? html`${UI.Alert({ variant: 'success', open: true, children: props.successMessage })}` : ''} +
+ ${UI.Input({ + label: props.currentPasswordLabel, + type: 'password', + value: props.currentPassword, + onInput: (event: Event) => props.setCurrentPassword(getInputValue(event)), + required: true, + passwordToggle: true, + })} + ${UI.Input({ + label: props.newPasswordLabel, + type: 'password', + value: props.newPassword, + onInput: (event: Event) => props.setNewPassword(getInputValue(event)), + required: true, + passwordToggle: true, + })} + ${UI.Input({ + label: props.confirmPasswordLabel, + type: 'password', + value: props.confirmPassword, + onInput: (event: Event) => props.setConfirmPassword(getInputValue(event)), + required: true, + passwordToggle: true, + })} +
+ ${UI.Button({ type: 'button', variant: 'default', onClick: props.close, children: 'Close' })} + ${UI.Button({ type: 'submit', variant: 'primary', loading: props.loading, children: props.submitLabel })} +
+
+ `, + })} + `; +} diff --git a/packages/lit-components/src/components/PortalChangePassword/index.ts b/packages/lit-components/src/components/PortalChangePassword/index.ts new file mode 100644 index 0000000000..2bca44ef62 --- /dev/null +++ b/packages/lit-components/src/components/PortalChangePassword/index.ts @@ -0,0 +1 @@ +export * from './PortalChangePassword'; diff --git a/packages/lit-components/src/components/PortalChangePassword/usePortalChangePassword.test.ts b/packages/lit-components/src/components/PortalChangePassword/usePortalChangePassword.test.ts new file mode 100644 index 0000000000..ba3f33a90b --- /dev/null +++ b/packages/lit-components/src/components/PortalChangePassword/usePortalChangePassword.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePortalChangePassword } from './usePortalChangePassword'; + +describe('usePortalChangePassword', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + universalHooksMock.useState.mockClear(); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = usePortalChangePassword({} as any); + + expect(result.currentPassword).toBe(''); + expect(result.newPassword).toBe(''); + expect(result.confirmPassword).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + expect(result.success).toBe(false); + expect(result.isOpen).toBe(false); + }); + + it('should open and close the modal', () => { + const result = usePortalChangePassword({} as any); + + result.open(); + result.close(); + + expect(universalHooksMock.setters[6]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[6]).toHaveBeenNthCalledWith(2, false); + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[5]).toHaveBeenCalledWith(false); + }); + + it('should validate empty fields', async () => { + const result = usePortalChangePassword({} as any); + const event = createEvent() as any; + + await result.onSubmit(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith('All fields are required'); + }); + + it('should validate password mismatch', async () => { + universalHooksMock.values.push('current-password', 'new-password', 'different-password'); + const result = usePortalChangePassword({} as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith('Passwords do not match'); + }); + + it('should validate minimum password length', async () => { + universalHooksMock.values.push('current-password', 'short', 'short'); + const result = usePortalChangePassword({ passwordMinLength: 10 } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith('Password must be at least 10 characters'); + }); + + it('should dispatch a change-password event on successful submit', async () => { + universalHooksMock.values.push('current-password', 'new-password-123', 'new-password-123'); + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalChangePassword({ passwordMinLength: 8 } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[4]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[5]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[4]).toHaveBeenNthCalledWith(2, false); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:change-password'); + expect(event.detail).toEqual({ + currentPassword: 'current-password', + newPassword: 'new-password-123', + }); + }); +}); diff --git a/packages/lit-components/src/components/PortalChangePassword/usePortalChangePassword.ts b/packages/lit-components/src/components/PortalChangePassword/usePortalChangePassword.ts new file mode 100644 index 0000000000..e6bfe30533 --- /dev/null +++ b/packages/lit-components/src/components/PortalChangePassword/usePortalChangePassword.ts @@ -0,0 +1,121 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { PortalChangePasswordProps } from './PortalChangePassword'; + +export function usePortalChangePassword(props: PortalChangePasswordProps) { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + function open() { + setIsOpen(true); + } + + function close() { + setIsOpen(false); + setError(''); + setSuccess(false); + } + + async function onSubmit(event: Event) { + event.preventDefault(); + if (!currentPassword || !newPassword) { + setError('All fields are required'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match'); + return; + } + if (newPassword.length < (Number(props.passwordMinLength) || 8)) { + setError(`Password must be at least ${props.passwordMinLength || 8} characters`); + return; + } + setLoading(true); + setError(''); + document.dispatchEvent( + new CustomEvent('sq:change-password', { + bubbles: true, + composed: true, + detail: { currentPassword, newPassword }, + }) + ); + setSuccess(true); + setLoading(false); + } + + return { + currentPassword, + setCurrentPassword, + newPassword, + setNewPassword, + confirmPassword, + setConfirmPassword, + error, + loading, + success, + isOpen, + open, + close, + onSubmit, + }; +} + +export function useDemoPortalChangePassword(props: PortalChangePasswordProps): ReturnType { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + function open() { + setIsOpen(true); + } + + function close() { + setIsOpen(false); + setError(''); + setSuccess(false); + } + + async function onSubmit(event: Event) { + event.preventDefault(); + if (!currentPassword || !newPassword) { + setError('All fields are required'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match'); + return; + } + if (newPassword.length < (Number(props.passwordMinLength) || 8)) { + setError(`Password must be at least ${props.passwordMinLength || 8} characters`); + return; + } + setLoading(true); + setError(''); + setSuccess(true); + setLoading(false); + } + + return { + currentPassword, + setCurrentPassword, + newPassword, + setNewPassword, + confirmPassword, + setConfirmPassword, + error, + loading, + success, + isOpen, + open, + close, + onSubmit, + }; +} diff --git a/packages/lit-components/src/components/PortalContainer/PortalContainer.stories.ts b/packages/lit-components/src/components/PortalContainer/PortalContainer.stories.ts new file mode 100644 index 0000000000..331725c368 --- /dev/null +++ b/packages/lit-components/src/components/PortalContainer/PortalContainer.stories.ts @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/PortalContainer', + component: 'sql-portal-container', + tags: ['autodocs'], + argTypes: { + direction: { control: 'select', options: ['row', 'column'] }, + padding: { control: 'select', options: ['none', 'small', 'medium', 'large', 'x-large', 'xx-large'] }, + gap: { control: 'select', options: ['none', 'small', 'medium', 'large', 'x-large'] }, + 'max-width': { control: 'text' }, + display: { control: 'select', options: ['flex', 'grid'] }, + 'grid-columns': { control: 'text' }, + }, + render: (args) => html` + +
Item 1
+
Item 2
+
Item 3
+
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithPadding: Story = { + args: { + padding: 'large', + }, +}; + +export const WithGap: Story = { + args: { + gap: 'medium', + }, +}; + +export const GridLayout: Story = { + args: { + display: 'grid', + gap: 'medium', + 'grid-columns': 'repeat(3, minmax(0, 1fr))', + }, +}; + +export const MaxWidth: Story = { + args: { + 'max-width': '640px', + padding: 'medium', + gap: 'medium', + }, +}; + +export const Row: Story = { + args: { + direction: 'row', + gap: 'medium', + }, +}; diff --git a/packages/lit-components/src/components/PortalContainer/PortalContainer.ts b/packages/lit-components/src/components/PortalContainer/PortalContainer.ts new file mode 100644 index 0000000000..fec7567e69 --- /dev/null +++ b/packages/lit-components/src/components/PortalContainer/PortalContainer.ts @@ -0,0 +1,49 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalContainerView } from './PortalContainerView'; + +export interface PortalContainerProps { + direction: 'row' | 'column'; + padding: 'none' | 'small' | 'medium' | 'large' | 'x-large' | 'xx-large'; + gap: 'none' | 'small' | 'medium' | 'large' | 'x-large'; + maxWidth?: string; + display: 'flex' | 'grid'; + gridColumns: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-container': HTMLElement; + } +} + +export const PortalContainer = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PortalContainerProps = { + direction: rawProps.direction === 'row' ? 'row' : 'column', + padding: + rawProps.padding === 'small' || + rawProps.padding === 'medium' || + rawProps.padding === 'large' || + rawProps.padding === 'x-large' || + rawProps.padding === 'xx-large' + ? rawProps.padding + : 'none', + gap: + rawProps.gap === 'small' || + rawProps.gap === 'medium' || + rawProps.gap === 'large' || + rawProps.gap === 'x-large' + ? rawProps.gap + : 'none', + maxWidth: typeof rawProps.maxWidth === 'string' ? rawProps.maxWidth : undefined, + display: rawProps.display === 'grid' ? 'grid' : 'flex', + gridColumns: typeof rawProps.gridColumns === 'string' ? rawProps.gridColumns : '1fr', + }; + + return PortalContainerView(props); + }, + 'sql-portal-container', + ['direction', 'padding', 'gap', 'max-width', 'display', 'grid-columns'] as const +); diff --git a/packages/lit-components/src/components/PortalContainer/PortalContainerView.ts b/packages/lit-components/src/components/PortalContainer/PortalContainerView.ts new file mode 100644 index 0000000000..35011ab840 --- /dev/null +++ b/packages/lit-components/src/components/PortalContainer/PortalContainerView.ts @@ -0,0 +1,25 @@ +import { html } from 'lit'; +import { PortalContainerProps } from './PortalContainer'; + +export function PortalContainerView(props: PortalContainerProps) { + return html` + +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/PortalContainer/index.ts b/packages/lit-components/src/components/PortalContainer/index.ts new file mode 100644 index 0000000000..03bbe1ab44 --- /dev/null +++ b/packages/lit-components/src/components/PortalContainer/index.ts @@ -0,0 +1 @@ +export * from './PortalContainer'; diff --git a/packages/lit-components/src/components/PortalEmailVerification/PortalEmailVerification.stories.ts b/packages/lit-components/src/components/PortalEmailVerification/PortalEmailVerification.stories.ts new file mode 100644 index 0000000000..7558ed5bb0 --- /dev/null +++ b/packages/lit-components/src/components/PortalEmailVerification/PortalEmailVerification.stories.ts @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { PortalEmailVerificationView } from './PortalEmailVerificationView'; + +const meta: Meta = { + title: 'Components/PortalEmailVerification', + component: 'sql-portal-email-verification', + tags: ['autodocs'], + argTypes: { + 'header-text': { control: 'text' }, + 'description-text': { control: 'text' }, + 'resend-text': { control: 'text' }, + 'success-text': { control: 'text' }, + 'redirect-url': { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Verify Your Email', + descriptionText: "We've sent a verification link to your email.", + resendText: 'Resend verification email', + successText: 'Email verified successfully!', +}; + +export const Default: Story = {}; + +export const Verified: Story = { + render: () => + PortalEmailVerificationView({ + ...baseProps, + verified: true, + token: 'demo-token', + resent: false, + loading: false, + onResend: async () => {}, + }), +}; + +export const Resent: Story = { + render: () => + PortalEmailVerificationView({ + ...baseProps, + verified: false, + token: '', + resent: true, + loading: false, + onResend: async () => {}, + }), +}; + +export const Loading: Story = { + render: () => + PortalEmailVerificationView({ + ...baseProps, + verified: false, + token: '', + resent: false, + loading: true, + onResend: async () => {}, + }), +}; diff --git a/packages/lit-components/src/components/PortalEmailVerification/PortalEmailVerification.ts b/packages/lit-components/src/components/PortalEmailVerification/PortalEmailVerification.ts new file mode 100644 index 0000000000..2c4f74d43b --- /dev/null +++ b/packages/lit-components/src/components/PortalEmailVerification/PortalEmailVerification.ts @@ -0,0 +1,39 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalEmailVerificationView } from './PortalEmailVerificationView'; +import { useDemoPortalEmailVerification, usePortalEmailVerification } from './usePortalEmailVerification'; + +export interface PortalEmailVerificationProps { + headerText: string; + descriptionText: string; + resendText: string; + successText: string; + redirectUrl?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-email-verification': HTMLElement; + } +} + +export const PortalEmailVerification = useComponent( + (host) => { + const rawProps = getProps(host); + const props: PortalEmailVerificationProps = { + headerText: rawProps.headerText || 'Verify Your Email', + descriptionText: + rawProps.descriptionText || "We've sent a verification link to your email.", + resendText: rawProps.resendText || 'Resend verification email', + successText: rawProps.successText || 'Email verified successfully!', + redirectUrl: rawProps.redirectUrl || undefined, + }; + + const hookProps = isDemo() ? useDemoPortalEmailVerification(props) : usePortalEmailVerification(props); + + return PortalEmailVerificationView({ ...props, ...hookProps }); + }, + 'sql-portal-email-verification', + ['header-text', 'description-text', 'resend-text', 'success-text', 'redirect-url'] as const +); diff --git a/packages/lit-components/src/components/PortalEmailVerification/PortalEmailVerificationView.ts b/packages/lit-components/src/components/PortalEmailVerification/PortalEmailVerificationView.ts new file mode 100644 index 0000000000..e6b6534705 --- /dev/null +++ b/packages/lit-components/src/components/PortalEmailVerification/PortalEmailVerificationView.ts @@ -0,0 +1,63 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { PortalEmailVerificationProps } from './PortalEmailVerification'; +import { usePortalEmailVerification } from './usePortalEmailVerification'; + +const styles = ` + :host { + display: block; + } + + .verification-container { + display: flex; + flex-direction: column; + gap: var(--sl-spacing-medium); + align-items: center; + text-align: center; + padding: var(--sl-spacing-x-large); + } + + h2, + p { + margin: 0; + } + + .success-icon { + font-size: 3rem; + color: var(--sl-color-success-600); + } + + .notice { + color: var(--sl-color-success-700); + } +`; + +export function PortalEmailVerificationView( + props: PortalEmailVerificationProps & ReturnType +) { + const isVerified = props.verified || Boolean(props.token); + + return html` + +
+

${props.headerText}

+ ${isVerified + ? html` + ${UI.Icon({ className: 'success-icon', name: 'check-circle' })} +

${props.successText}

+ ` + : html` +

${props.descriptionText}

+ ${props.resent ? html`

Verification email resent.

` : ''} + ${UI.Button({ + disabled: props.loading, + onClick: () => props.onResend(), + prefix: props.loading ? UI.Spinner({ style: 'font-size: 1rem;' }) : undefined, + children: props.resendText, + })} + `} +
+ `; +} diff --git a/packages/lit-components/src/components/PortalEmailVerification/index.ts b/packages/lit-components/src/components/PortalEmailVerification/index.ts new file mode 100644 index 0000000000..3bd7979678 --- /dev/null +++ b/packages/lit-components/src/components/PortalEmailVerification/index.ts @@ -0,0 +1 @@ +export * from './PortalEmailVerification'; diff --git a/packages/lit-components/src/components/PortalEmailVerification/usePortalEmailVerification.test.ts b/packages/lit-components/src/components/PortalEmailVerification/usePortalEmailVerification.test.ts new file mode 100644 index 0000000000..7cca9d526a --- /dev/null +++ b/packages/lit-components/src/components/PortalEmailVerification/usePortalEmailVerification.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePortalEmailVerification } from './usePortalEmailVerification'; + +describe('usePortalEmailVerification', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + universalHooksMock.useState.mockClear(); + + const windowMock = { + location: { search: '' }, + history: { + replaceState: vi.fn((_state: unknown, _title: string, url: string) => { + windowMock.location.search = url.includes('?') ? url.slice(url.indexOf('?')) : ''; + }), + }, + }; + + vi.stubGlobal('window', windowMock); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = usePortalEmailVerification({} as any); + + expect(result.verified).toBe(false); + expect(result.token).toBe(''); + expect(result.resent).toBe(false); + expect(result.loading).toBe(false); + }); + + it('should extract the token from the url', () => { + window.history.replaceState({}, '', '/?oobCode=test-token'); + + const result = usePortalEmailVerification({} as any); + + expect(result.token).toBe('test-token'); + }); + + it('should dispatch a resend-verification event', async () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalEmailVerification({} as any); + + await result.onResend(); + + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(2, false); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:resend-verification'); + expect(event.detail).toBe(null); + }); +}); diff --git a/packages/lit-components/src/components/PortalEmailVerification/usePortalEmailVerification.ts b/packages/lit-components/src/components/PortalEmailVerification/usePortalEmailVerification.ts new file mode 100644 index 0000000000..d1618b36a0 --- /dev/null +++ b/packages/lit-components/src/components/PortalEmailVerification/usePortalEmailVerification.ts @@ -0,0 +1,40 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { PortalEmailVerificationProps } from './PortalEmailVerification'; + +export function usePortalEmailVerification(_props: PortalEmailVerificationProps) { + const [verified] = useState(false); + const [resent, setResent] = useState(false); + const [loading, setLoading] = useState(false); + + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get('oobCode') || urlParams.get('token') || ''; + + async function onResend() { + setLoading(true); + const event = new CustomEvent('sq:resend-verification', { + bubbles: true, + composed: true, + }); + document.dispatchEvent(event); + setResent(true); + setLoading(false); + } + + return { verified, token, resent, loading, onResend }; +} + +export function useDemoPortalEmailVerification( + _props: PortalEmailVerificationProps +): ReturnType { + const [verified] = useState(false); + const [resent, setResent] = useState(false); + const [loading, setLoading] = useState(false); + + async function onResend() { + setLoading(true); + setResent(true); + setLoading(false); + } + + return { verified, token: '', resent, loading, onResend }; +} diff --git a/packages/lit-components/src/components/PortalFooter/PortalFooter.stories.ts b/packages/lit-components/src/components/PortalFooter/PortalFooter.stories.ts new file mode 100644 index 0000000000..c8f0f1af03 --- /dev/null +++ b/packages/lit-components/src/components/PortalFooter/PortalFooter.stories.ts @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/PortalFooter', + component: 'sql-portal-footer', + tags: ['autodocs'], + argTypes: { + 'support-email': { control: 'text' }, + 'support-link': { control: 'text' }, + 'faq-link': { control: 'text' }, + 'faq-text': { control: 'text' }, + 'terms-link': { control: 'text' }, + 'terms-text': { control: 'text' }, + 'show-powered-by': { control: 'boolean' }, + 'text-color': { control: 'color' }, + padding: { control: 'select', options: ['none', 'small', 'medium', 'large'] }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + 'faq-link': 'https://example.com/faq', + 'terms-link': 'https://example.com/terms', + }, +}; + +export const WithLinks: Story = { + args: { + 'support-link': 'https://example.com/support', + 'faq-link': 'https://example.com/faq', + 'terms-link': 'https://example.com/terms', + }, +}; + +export const NoPoweredBy: Story = { + args: { + 'faq-link': 'https://example.com/faq', + 'show-powered-by': false, + }, +}; + +export const CustomColors: Story = { + args: { + 'faq-link': 'https://example.com/faq', + 'terms-link': 'https://example.com/terms', + 'text-color': '#2563eb', + }, +}; + +export const WithEmail: Story = { + args: { + 'support-email': 'support@example.com', + 'faq-link': 'https://example.com/faq', + }, +}; diff --git a/packages/lit-components/src/components/PortalFooter/PortalFooter.ts b/packages/lit-components/src/components/PortalFooter/PortalFooter.ts new file mode 100644 index 0000000000..2b78250ef7 --- /dev/null +++ b/packages/lit-components/src/components/PortalFooter/PortalFooter.ts @@ -0,0 +1,61 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalFooterView } from './PortalFooterView'; + +export interface PortalFooterProps { + supportEmail?: string; + supportLink?: string; + faqLink?: string; + faqText: string; + termsLink?: string; + termsText: string; + showPoweredBy: boolean; + textColor?: string; + padding: 'none' | 'small' | 'medium' | 'large'; +} + +const parseBoolean = (value: unknown, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-footer': HTMLElement; + } +} + +export const PortalFooter = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PortalFooterProps = { + supportEmail: typeof rawProps.supportEmail === 'string' ? rawProps.supportEmail : undefined, + supportLink: typeof rawProps.supportLink === 'string' ? rawProps.supportLink : undefined, + faqLink: typeof rawProps.faqLink === 'string' ? rawProps.faqLink : undefined, + faqText: typeof rawProps.faqText === 'string' ? rawProps.faqText : 'FAQ', + termsLink: typeof rawProps.termsLink === 'string' ? rawProps.termsLink : undefined, + termsText: typeof rawProps.termsText === 'string' ? rawProps.termsText : 'Terms & Conditions', + showPoweredBy: parseBoolean(rawProps.showPoweredBy, true), + textColor: typeof rawProps.textColor === 'string' ? rawProps.textColor : undefined, + padding: + rawProps.padding === 'none' || rawProps.padding === 'small' || rawProps.padding === 'large' + ? rawProps.padding + : 'medium', + }; + + return PortalFooterView(props); + }, + 'sql-portal-footer', + [ + 'support-email', + 'support-link', + 'faq-link', + 'faq-text', + 'terms-link', + 'terms-text', + 'show-powered-by', + 'text-color', + 'padding', + ] as const +); diff --git a/packages/lit-components/src/components/PortalFooter/PortalFooterView.ts b/packages/lit-components/src/components/PortalFooter/PortalFooterView.ts new file mode 100644 index 0000000000..1ee70decd2 --- /dev/null +++ b/packages/lit-components/src/components/PortalFooter/PortalFooterView.ts @@ -0,0 +1,44 @@ +import { html } from 'lit'; +import { PortalFooterProps } from './PortalFooter'; + +export function PortalFooterView(props: PortalFooterProps) { + return html` + +
+ ${props.supportEmail ? html`Support` : ''} + ${props.supportLink ? html`Support` : ''} + ${props.faqLink ? html`${props.faqText}` : ''} + ${props.termsLink ? html`${props.termsText}` : ''} + ${props.showPoweredBy ? html`Powered by SaaSquatch` : ''} +
+ `; +} diff --git a/packages/lit-components/src/components/PortalFooter/index.ts b/packages/lit-components/src/components/PortalFooter/index.ts new file mode 100644 index 0000000000..e41411f672 --- /dev/null +++ b/packages/lit-components/src/components/PortalFooter/index.ts @@ -0,0 +1 @@ +export * from './PortalFooter'; diff --git a/packages/lit-components/src/components/PortalForgotPassword/PortalForgotPassword.stories.ts b/packages/lit-components/src/components/PortalForgotPassword/PortalForgotPassword.stories.ts new file mode 100644 index 0000000000..4ff001cf0b --- /dev/null +++ b/packages/lit-components/src/components/PortalForgotPassword/PortalForgotPassword.stories.ts @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { PortalForgotPasswordView } from './PortalForgotPasswordView'; + +const meta: Meta = { + title: 'Components/PortalForgotPassword', + component: 'sql-portal-forgot-password', + tags: ['autodocs'], + argTypes: { + 'email-label': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'back-label': { control: 'text' }, + 'header-text': { control: 'text' }, + 'description-text': { control: 'text' }, + 'success-message': { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + emailLabel: 'Email', + submitLabel: 'Send Reset Link', + backLabel: 'Back to Login', + headerText: 'Forgot Password', + descriptionText: "Enter your email and we'll send you a reset link.", + successMessage: 'Check your email for a reset link.', + email: 'jane@example.com', + setEmail: () => undefined, + error: '', + loading: false, + success: false, + onSubmit: (event: Event) => event.preventDefault(), + onBack: () => undefined, +}; + +export const Default: Story = {}; + +export const WithError: Story = { + render: () => + PortalForgotPasswordView({ + ...baseProps, + error: 'Email is required', + email: '', + }), +}; + +export const Success: Story = { + render: () => + PortalForgotPasswordView({ + ...baseProps, + success: true, + }), +}; + +export const CustomLabels: Story = { + args: { + 'email-label': 'Work Email', + 'submit-label': 'Email Me a Reset Link', + 'back-label': 'Return to sign in', + 'header-text': 'Reset your password', + 'description-text': 'Share your email address to receive a secure reset link.', + 'success-message': 'Reset instructions have been sent.', + }, +}; diff --git a/packages/lit-components/src/components/PortalForgotPassword/PortalForgotPassword.ts b/packages/lit-components/src/components/PortalForgotPassword/PortalForgotPassword.ts new file mode 100644 index 0000000000..3ae8273cc3 --- /dev/null +++ b/packages/lit-components/src/components/PortalForgotPassword/PortalForgotPassword.ts @@ -0,0 +1,46 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalForgotPasswordView } from './PortalForgotPasswordView'; +import { useDemoPortalForgotPassword, usePortalForgotPassword } from './usePortalForgotPassword'; + +export interface PortalForgotPasswordProps { + emailLabel: string; + submitLabel: string; + backLabel: string; + headerText: string; + descriptionText: string; + successMessage: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-forgot-password': HTMLElement; + } +} + +export const PortalForgotPassword = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PortalForgotPasswordProps = { + emailLabel: typeof rawProps.emailLabel === 'string' ? rawProps.emailLabel : 'Email', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Send Reset Link', + backLabel: typeof rawProps.backLabel === 'string' ? rawProps.backLabel : 'Back to Login', + headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Forgot Password', + descriptionText: + typeof rawProps.descriptionText === 'string' + ? rawProps.descriptionText + : "Enter your email and we'll send you a reset link.", + successMessage: + typeof rawProps.successMessage === 'string' + ? rawProps.successMessage + : 'Check your email for a reset link.', + }; + + const hookProps = isDemo() ? useDemoPortalForgotPassword(props) : usePortalForgotPassword(props); + + return PortalForgotPasswordView({ ...props, ...hookProps }); + }, + 'sql-portal-forgot-password', + ['email-label', 'submit-label', 'back-label', 'header-text', 'description-text', 'success-message'] as const +); diff --git a/packages/lit-components/src/components/PortalForgotPassword/PortalForgotPasswordView.ts b/packages/lit-components/src/components/PortalForgotPassword/PortalForgotPasswordView.ts new file mode 100644 index 0000000000..1a848fae62 --- /dev/null +++ b/packages/lit-components/src/components/PortalForgotPassword/PortalForgotPasswordView.ts @@ -0,0 +1,83 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PortalForgotPasswordProps } from './PortalForgotPassword'; +import { usePortalForgotPassword } from './usePortalForgotPassword'; + +const getInputValue = (event: Event) => ((event.target as HTMLInputElement & { value: string })?.value ?? ''); + +export function PortalForgotPasswordView(props: PortalForgotPasswordProps & ReturnType) { + return html` + +
+

${props.headerText}

+

${props.descriptionText}

+ ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} + ${props.success + ? html`${UI.Alert({ variant: 'success', open: true, children: props.successMessage })}` + : html` +
+ ${UI.Input({ + label: props.emailLabel, + type: 'email', + value: props.email, + onInput: (event: Event) => props.setEmail(getInputValue(event)), + required: true, + })} + ${UI.Button({ type: 'submit', variant: 'primary', loading: props.loading, style: 'width: 100%;', children: props.submitLabel })} +
+ `} + +
+ `; +} diff --git a/packages/lit-components/src/components/PortalForgotPassword/index.ts b/packages/lit-components/src/components/PortalForgotPassword/index.ts new file mode 100644 index 0000000000..afc62efcd7 --- /dev/null +++ b/packages/lit-components/src/components/PortalForgotPassword/index.ts @@ -0,0 +1 @@ +export * from './PortalForgotPassword'; diff --git a/packages/lit-components/src/components/PortalForgotPassword/usePortalForgotPassword.test.ts b/packages/lit-components/src/components/PortalForgotPassword/usePortalForgotPassword.test.ts new file mode 100644 index 0000000000..eae74aa62b --- /dev/null +++ b/packages/lit-components/src/components/PortalForgotPassword/usePortalForgotPassword.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePortalForgotPassword } from './usePortalForgotPassword'; + +describe('usePortalForgotPassword', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + universalHooksMock.useState.mockClear(); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = usePortalForgotPassword({} as any); + + expect(result.email).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + expect(result.success).toBe(false); + }); + + it('should validate an empty email', async () => { + const result = usePortalForgotPassword({} as any); + const event = createEvent() as any; + + await result.onSubmit(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith('Email is required'); + }); + + it('should dispatch a forgot-password event on submit', async () => { + universalHooksMock.values.push('test@example.com'); + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalForgotPassword({} as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[2]).toHaveBeenNthCalledWith(2, false); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:forgot-password'); + expect(event.detail).toEqual({ email: 'test@example.com' }); + }); + + it('should dispatch a navigate event to login on back', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalForgotPassword({} as any); + + result.onBack(); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:navigate'); + expect(event.detail).toEqual({ path: '/login' }); + }); +}); diff --git a/packages/lit-components/src/components/PortalForgotPassword/usePortalForgotPassword.ts b/packages/lit-components/src/components/PortalForgotPassword/usePortalForgotPassword.ts new file mode 100644 index 0000000000..8880013e04 --- /dev/null +++ b/packages/lit-components/src/components/PortalForgotPassword/usePortalForgotPassword.ts @@ -0,0 +1,67 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { PortalForgotPasswordProps } from './PortalForgotPassword'; + +export function usePortalForgotPassword(props: PortalForgotPasswordProps) { + const [email, setEmail] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + void props; + + async function onSubmit(event: Event) { + event.preventDefault(); + if (!email) { + setError('Email is required'); + return; + } + setLoading(true); + setError(''); + document.dispatchEvent( + new CustomEvent('sq:forgot-password', { + bubbles: true, + composed: true, + detail: { email }, + }) + ); + setSuccess(true); + setLoading(false); + } + + function onBack() { + document.dispatchEvent( + new CustomEvent('sq:navigate', { + bubbles: true, + composed: true, + detail: { path: '/login' }, + }) + ); + } + + return { email, setEmail, error, loading, success, onSubmit, onBack }; +} + +export function useDemoPortalForgotPassword(_props: PortalForgotPasswordProps): ReturnType { + const [email, setEmail] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + async function onSubmit(event: Event) { + event.preventDefault(); + if (!email) { + setError('Email is required'); + return; + } + setLoading(true); + setError(''); + setSuccess(true); + setLoading(false); + } + + function onBack() { + console.log('Demo back to login'); + } + + return { email, setEmail, error, loading, success, onSubmit, onBack }; +} diff --git a/packages/lit-components/src/components/PortalFrame/PortalFrame.stories.ts b/packages/lit-components/src/components/PortalFrame/PortalFrame.stories.ts new file mode 100644 index 0000000000..2b1bf4609d --- /dev/null +++ b/packages/lit-components/src/components/PortalFrame/PortalFrame.stories.ts @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/PortalFrame', + component: 'sql-portal-frame', + tags: ['autodocs'], + argTypes: { + 'max-width': { control: 'text' }, + 'background-color': { control: 'color' }, + 'show-border': { control: 'boolean' }, + 'border-color': { control: 'color' }, + }, + render: (args) => html` + +
Portal Header
+
+ Main content goes here. +
+
Portal Footer
+
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomWidth: Story = { + args: { + 'max-width': '1024px', + }, +}; + +export const CustomBackground: Story = { + args: { + 'background-color': '#f8fafc', + }, +}; + +export const NoBorder: Story = { + args: { + 'show-border': false, + }, +}; + +export const WithHeaderAndFooter: Story = {}; diff --git a/packages/lit-components/src/components/PortalFrame/PortalFrame.ts b/packages/lit-components/src/components/PortalFrame/PortalFrame.ts new file mode 100644 index 0000000000..9931ac85c0 --- /dev/null +++ b/packages/lit-components/src/components/PortalFrame/PortalFrame.ts @@ -0,0 +1,38 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalFrameView } from './PortalFrameView'; + +export interface PortalFrameProps { + maxWidth: string; + backgroundColor?: string; + showBorder: boolean; + borderColor?: string; +} + +const parseBoolean = (value: unknown, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-frame': HTMLElement; + } +} + +export const PortalFrame = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PortalFrameProps = { + maxWidth: typeof rawProps.maxWidth === 'string' ? rawProps.maxWidth : '800px', + backgroundColor: typeof rawProps.backgroundColor === 'string' ? rawProps.backgroundColor : undefined, + showBorder: parseBoolean(rawProps.showBorder, true), + borderColor: typeof rawProps.borderColor === 'string' ? rawProps.borderColor : undefined, + }; + + return PortalFrameView(props); + }, + 'sql-portal-frame', + ['max-width', 'background-color', 'show-border', 'border-color'] as const +); diff --git a/packages/lit-components/src/components/PortalFrame/PortalFrameView.ts b/packages/lit-components/src/components/PortalFrame/PortalFrameView.ts new file mode 100644 index 0000000000..b2ba55323a --- /dev/null +++ b/packages/lit-components/src/components/PortalFrame/PortalFrameView.ts @@ -0,0 +1,43 @@ +import { html } from 'lit'; +import { PortalFrameProps } from './PortalFrame'; + +export function PortalFrameView(props: PortalFrameProps) { + const borderColor = props.borderColor || 'var(--sl-color-neutral-200)'; + + return html` + +
+
+
+
+
+ `; +} diff --git a/packages/lit-components/src/components/PortalFrame/index.ts b/packages/lit-components/src/components/PortalFrame/index.ts new file mode 100644 index 0000000000..ab13e0d34e --- /dev/null +++ b/packages/lit-components/src/components/PortalFrame/index.ts @@ -0,0 +1 @@ +export * from './PortalFrame'; diff --git a/packages/lit-components/src/components/PortalGoogleLogin/PortalGoogleLogin.stories.ts b/packages/lit-components/src/components/PortalGoogleLogin/PortalGoogleLogin.stories.ts new file mode 100644 index 0000000000..cbc6365ec3 --- /dev/null +++ b/packages/lit-components/src/components/PortalGoogleLogin/PortalGoogleLogin.stories.ts @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/PortalGoogleLogin', + component: 'sql-portal-google-login', + tags: ['autodocs'], + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Large: Story = { + args: { + size: 'large', + }, +}; + +export const CustomText: Story = { + args: { + 'button-text': 'Continue with Google', + }, +}; diff --git a/packages/lit-components/src/components/PortalGoogleLogin/PortalGoogleLogin.ts b/packages/lit-components/src/components/PortalGoogleLogin/PortalGoogleLogin.ts new file mode 100644 index 0000000000..bb7893a4bb --- /dev/null +++ b/packages/lit-components/src/components/PortalGoogleLogin/PortalGoogleLogin.ts @@ -0,0 +1,33 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalGoogleLoginView } from './PortalGoogleLoginView'; + +export interface PortalGoogleLoginProps { + buttonText: string; + clientId?: string; + size: 'small' | 'medium' | 'large'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-google-login': HTMLElement; + } +} + +export const PortalGoogleLogin = useComponent( + (host) => { + const rawProps = getProps(host); + const props: PortalGoogleLoginProps = { + buttonText: rawProps.buttonText || 'Sign in with Google', + clientId: rawProps.clientId || undefined, + size: + rawProps.size === 'small' || rawProps.size === 'large' + ? rawProps.size + : 'medium', + }; + + return PortalGoogleLoginView(props); + }, + 'sql-portal-google-login', + ['button-text', 'client-id', 'size'] as const +); diff --git a/packages/lit-components/src/components/PortalGoogleLogin/PortalGoogleLoginView.ts b/packages/lit-components/src/components/PortalGoogleLogin/PortalGoogleLoginView.ts new file mode 100644 index 0000000000..cffa90bb51 --- /dev/null +++ b/packages/lit-components/src/components/PortalGoogleLogin/PortalGoogleLoginView.ts @@ -0,0 +1,41 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { PortalGoogleLoginProps } from './PortalGoogleLogin'; + +export function PortalGoogleLoginView(props: PortalGoogleLoginProps) { + return html` + + ${UI.Button({ + className: 'google-btn', + variant: 'neutral', + size: props.size, + style: 'width: 100%;', + onClick: () => { + const event = new CustomEvent('sq:google-login', { + bubbles: true, + composed: true, + detail: { clientId: props.clientId }, + }); + document.dispatchEvent(event); + }, + prefix: html` + + + + + `, + children: props.buttonText, + })} + `; +} diff --git a/packages/lit-components/src/components/PortalGoogleLogin/index.ts b/packages/lit-components/src/components/PortalGoogleLogin/index.ts new file mode 100644 index 0000000000..19b40a13ca --- /dev/null +++ b/packages/lit-components/src/components/PortalGoogleLogin/index.ts @@ -0,0 +1 @@ +export * from './PortalGoogleLogin'; diff --git a/packages/lit-components/src/components/PortalGoogleRegistrationForm/PortalGoogleRegistrationForm.stories.ts b/packages/lit-components/src/components/PortalGoogleRegistrationForm/PortalGoogleRegistrationForm.stories.ts new file mode 100644 index 0000000000..7b981a4508 --- /dev/null +++ b/packages/lit-components/src/components/PortalGoogleRegistrationForm/PortalGoogleRegistrationForm.stories.ts @@ -0,0 +1,107 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { PortalGoogleRegistrationFormView } from './PortalGoogleRegistrationFormView'; + +const meta: Meta = { + title: 'Components/PortalGoogleRegistrationForm', + component: 'sql-portal-google-registration-form', + tags: ['autodocs'], + argTypes: { + 'page-label': { control: 'text' }, + 'email-label': { control: 'text' }, + 'password-label': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'login-label': { control: 'text' }, + 'confirm-password-label': { control: 'text' }, + 'confirm-password': { control: 'boolean' }, + 'google-button-text': { + control: 'select', + options: ['signup_with', 'signin_with', 'continue_with', 'signin'], + }, + }, + render: (args) => html` + +

+ By continuing, you agree to the terms and conditions. +

+
+ `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + nextPage: '/', + redirectPath: '/verifyEmail', + emailLabel: 'Email', + passwordLabel: 'Password', + submitLabel: 'Register', + loginLabel: 'Sign in', + confirmPasswordLabel: 'Confirm Password', + confirmPassword: false, + disablePasswordValidation: false, + hideInputs: false, + pageLabel: 'Register', + loginPath: '/login', + loginCTA: 'Already have an account?', + requiredFieldErrorMessage: 'Cannot be empty', + networkErrorMessage: 'Network request failed.', + passwordMismatchErrorMessage: 'Passwords do not match.', + invalidEmailErrorMessage: 'Must be a valid email address', + formDisabledErrorMessage: 'The registration form is currently disabled.', + meetsRequirementsText: 'Password has met all requirements', + doesNotMeetRequirementsText: 'Password must meet the following requirements:', + minErrorText: 'be a minimum of 8 characters', + uppercaseErrorText: 'contain at least 1 uppercase character', + lowercaseErrorText: 'contain at least 1 lowercase character', + hasErrorText: 'contain at least 1 number or symbol', + googleButtonText: 'signup_with', + mode: 'google' as const, + baseEmail: 'testuser@example.com', + setBaseEmail: (_value: string) => undefined, + firstName: 'Test', + setFirstName: (_value: string) => undefined, + lastName: 'User', + setLastName: (_value: string) => undefined, + email: 'testuser@example.com', + setEmail: (_value: string) => undefined, + password: '', + setPassword: (_value: string) => undefined, + confirmPasswordValue: '', + setConfirmPasswordValue: (_value: string) => undefined, + validationErrors: {}, + loading: false, + error: '', + passwordHelpText: '', + onBaseSubmit: async (event: Event) => event.preventDefault(), + onGoogleInit: (_event: Event) => undefined, + onSubmit: async (event: Event) => event.preventDefault(), + onLogin: () => undefined, +}; + +export const Default: Story = {}; + +export const GooglePrefilled: Story = { + render: () => PortalGoogleRegistrationFormView(baseProps), +}; + +export const RegisterWithError: Story = { + render: () => + PortalGoogleRegistrationFormView({ + ...baseProps, + mode: 'manual', + password: 'short', + error: 'Password must meet the following requirements.', + }), +}; diff --git a/packages/lit-components/src/components/PortalGoogleRegistrationForm/PortalGoogleRegistrationForm.ts b/packages/lit-components/src/components/PortalGoogleRegistrationForm/PortalGoogleRegistrationForm.ts new file mode 100644 index 0000000000..c832aafc86 --- /dev/null +++ b/packages/lit-components/src/components/PortalGoogleRegistrationForm/PortalGoogleRegistrationForm.ts @@ -0,0 +1,170 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalGoogleRegistrationFormView } from './PortalGoogleRegistrationFormView'; +import { + type PortalGoogleRegistrationFormHookResult, + useDemoPortalGoogleRegistrationForm, + usePortalGoogleRegistrationForm, +} from './usePortalGoogleRegistrationForm'; + +export interface PortalGoogleRegistrationFormProps { + nextPage: string; + redirectPath: string; + emailLabel: string; + passwordLabel: string; + submitLabel: string; + loginLabel: string; + confirmPasswordLabel: string; + confirmPassword: boolean; + disablePasswordValidation: boolean; + hideInputs: boolean; + pageLabel: string; + loginPath: string; + loginCTA: string; + requiredFieldErrorMessage: string; + networkErrorMessage: string; + passwordMismatchErrorMessage: string; + invalidEmailErrorMessage: string; + formDisabledErrorMessage: string; + meetsRequirementsText: string; + doesNotMeetRequirementsText: string; + minErrorText: string; + uppercaseErrorText: string; + lowercaseErrorText: string; + hasErrorText: string; + formKey?: string; + googleButtonText: string; +} + +const parseBoolean = (value: unknown, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-google-registration-form': HTMLElement; + } +} + +function getDefaultProps( + rawProps: Partial> +): PortalGoogleRegistrationFormProps { + return { + nextPage: typeof rawProps.nextPage === 'string' ? rawProps.nextPage : '/', + redirectPath: + typeof rawProps.redirectPath === 'string' ? rawProps.redirectPath : '/verifyEmail', + emailLabel: typeof rawProps.emailLabel === 'string' ? rawProps.emailLabel : 'Email', + passwordLabel: + typeof rawProps.passwordLabel === 'string' ? rawProps.passwordLabel : 'Password', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Register', + loginLabel: typeof rawProps.loginLabel === 'string' ? rawProps.loginLabel : 'Sign in', + confirmPasswordLabel: + typeof rawProps.confirmPasswordLabel === 'string' + ? rawProps.confirmPasswordLabel + : 'Confirm Password', + confirmPassword: parseBoolean(rawProps.confirmPassword, false), + disablePasswordValidation: parseBoolean(rawProps.disablePasswordValidation, false), + hideInputs: parseBoolean(rawProps.hideInputs, false), + pageLabel: typeof rawProps.pageLabel === 'string' ? rawProps.pageLabel : 'Register', + loginPath: typeof rawProps.loginPath === 'string' ? rawProps.loginPath : '/login', + loginCTA: + typeof rawProps.loginCTA === 'string' + ? rawProps.loginCTA + : 'Already have an account?', + requiredFieldErrorMessage: + typeof rawProps.requiredFieldErrorMessage === 'string' + ? rawProps.requiredFieldErrorMessage + : 'Cannot be empty', + networkErrorMessage: + typeof rawProps.networkErrorMessage === 'string' + ? rawProps.networkErrorMessage + : 'Network request failed.', + passwordMismatchErrorMessage: + typeof rawProps.passwordMismatchErrorMessage === 'string' + ? rawProps.passwordMismatchErrorMessage + : 'Passwords do not match.', + invalidEmailErrorMessage: + typeof rawProps.invalidEmailErrorMessage === 'string' + ? rawProps.invalidEmailErrorMessage + : 'Must be a valid email address', + formDisabledErrorMessage: + typeof rawProps.formDisabledErrorMessage === 'string' + ? rawProps.formDisabledErrorMessage + : 'The registration form is currently disabled.', + meetsRequirementsText: + typeof rawProps.meetsRequirementsText === 'string' + ? rawProps.meetsRequirementsText + : 'Password has met all requirements', + doesNotMeetRequirementsText: + typeof rawProps.doesNotMeetRequirementsText === 'string' + ? rawProps.doesNotMeetRequirementsText + : 'Password must meet the following requirements:', + minErrorText: + typeof rawProps.minErrorText === 'string' + ? rawProps.minErrorText + : 'be a minimum of 8 characters', + uppercaseErrorText: + typeof rawProps.uppercaseErrorText === 'string' + ? rawProps.uppercaseErrorText + : 'contain at least 1 uppercase character', + lowercaseErrorText: + typeof rawProps.lowercaseErrorText === 'string' + ? rawProps.lowercaseErrorText + : 'contain at least 1 lowercase character', + hasErrorText: + typeof rawProps.hasErrorText === 'string' + ? rawProps.hasErrorText + : 'contain at least 1 number or symbol', + formKey: typeof rawProps.formKey === 'string' ? rawProps.formKey : undefined, + googleButtonText: + typeof rawProps.googleButtonText === 'string' + ? rawProps.googleButtonText + : 'signup_with', + }; +} + +export const PortalGoogleRegistrationForm = useComponent( + (host) => { + const rawProps = getProps(host) as Partial< + Record + >; + const props = getDefaultProps(rawProps); + const hookProps = (isDemo() + ? useDemoPortalGoogleRegistrationForm(props) + : usePortalGoogleRegistrationForm(props)) as PortalGoogleRegistrationFormHookResult; + + return PortalGoogleRegistrationFormView({ ...props, ...hookProps }); + }, + 'sql-portal-google-registration-form', + [ + 'next-page', + 'redirect-path', + 'email-label', + 'password-label', + 'submit-label', + 'login-label', + 'confirm-password-label', + 'confirm-password', + 'disable-password-validation', + 'hide-inputs', + 'page-label', + 'login-path', + 'login-cta', + 'required-field-error-message', + 'network-error-message', + 'password-mismatch-error-message', + 'invalid-email-error-message', + 'form-disabled-error-message', + 'meets-requirements-text', + 'does-not-meet-requirements-text', + 'min-error-text', + 'uppercase-error-text', + 'lowercase-error-text', + 'has-error-text', + 'form-key', + 'google-button-text', + ] as const +); diff --git a/packages/lit-components/src/components/PortalGoogleRegistrationForm/PortalGoogleRegistrationFormView.ts b/packages/lit-components/src/components/PortalGoogleRegistrationForm/PortalGoogleRegistrationFormView.ts new file mode 100644 index 0000000000..e348639580 --- /dev/null +++ b/packages/lit-components/src/components/PortalGoogleRegistrationForm/PortalGoogleRegistrationFormView.ts @@ -0,0 +1,164 @@ +import { html, nothing } from 'lit'; +import { UI } from '../../ui'; +import { BaseRegistrationView } from '../BaseRegistration/BaseRegistrationView'; +import type { PortalGoogleRegistrationFormProps } from './PortalGoogleRegistrationForm'; +import type { PortalGoogleRegistrationFormHookResult } from './usePortalGoogleRegistrationForm'; + +const getValue = (event: Event) => + String((event.target as HTMLInputElement & { value?: string })?.value ?? ''); + +export function PortalGoogleRegistrationFormView( + props: PortalGoogleRegistrationFormProps & PortalGoogleRegistrationFormHookResult +) { + if (props.mode === 'base') { + return BaseRegistrationView({ + pageLabel: props.pageLabel, + emailLabel: props.emailLabel, + submitLabel: props.submitLabel, + requiredFieldErrorMessage: props.requiredFieldErrorMessage, + invalidEmailErrorMessage: props.invalidEmailErrorMessage, + showGoogleButton: true, + showSecondaryButton: true, + email: props.baseEmail, + setEmail: props.setBaseEmail, + validationErrors: props.validationErrors, + loading: props.loading, + onSubmit: props.onBaseSubmit, + formData: html``, + terms: html``, + googleButton: html` + + `, + secondaryButton: html` + + `, + }); + } + + const showPasswordFields = props.mode !== 'google'; + const showDefaultInputs = !props.hideInputs; + + return html` + +
+
+

${props.pageLabel}

+ ${props.mode === 'google' + ? html`

+ Google pre-filled your registration details. +

` + : nothing} +
+
+ ${showDefaultInputs + ? html` +
+ ${UI.Input({ + label: 'First Name', + value: props.firstName, + disabled: props.loading, + onInput: (event: Event) => props.setFirstName(getValue(event)), + })} + ${UI.Input({ + label: 'Last Name', + value: props.lastName, + disabled: props.loading, + onInput: (event: Event) => props.setLastName(getValue(event)), + })} +
+ ${UI.Input({ + type: 'email', + label: props.emailLabel, + value: props.email, + disabled: props.loading || props.mode === 'google', + onInput: (event: Event) => props.setEmail(getValue(event)), + })} + ` + : nothing} + + ${showPasswordFields + ? html` + ${UI.Input({ + type: 'password', + label: props.passwordLabel, + value: props.password, + disabled: props.loading, + passwordToggle: true, + onInput: (event: Event) => props.setPassword(getValue(event)), + helpText: props.passwordHelpText, + })} + ${props.confirmPassword + ? html` + ${UI.Input({ + type: 'password', + label: props.confirmPasswordLabel, + value: props.confirmPasswordValue, + disabled: props.loading, + passwordToggle: true, + onInput: (event: Event) => props.setConfirmPasswordValue(getValue(event)), + })} + ` + : nothing} + ` + : nothing} + + ${props.error ? html`

${props.error}

` : nothing} + ${UI.Button({ type: 'submit', variant: 'primary', loading: props.loading, children: props.submitLabel })} +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/PortalGoogleRegistrationForm/index.ts b/packages/lit-components/src/components/PortalGoogleRegistrationForm/index.ts new file mode 100644 index 0000000000..61507eedd6 --- /dev/null +++ b/packages/lit-components/src/components/PortalGoogleRegistrationForm/index.ts @@ -0,0 +1 @@ +export * from './PortalGoogleRegistrationForm'; diff --git a/packages/lit-components/src/components/PortalGoogleRegistrationForm/usePortalGoogleRegistrationForm.test.ts b/packages/lit-components/src/components/PortalGoogleRegistrationForm/usePortalGoogleRegistrationForm.test.ts new file mode 100644 index 0000000000..e393e0dae9 --- /dev/null +++ b/packages/lit-components/src/components/PortalGoogleRegistrationForm/usePortalGoogleRegistrationForm.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePortalGoogleRegistrationForm } from './usePortalGoogleRegistrationForm'; + +describe('usePortalGoogleRegistrationForm', () => { + const props = { + nextPage: '/next', + redirectPath: '/verify-email', + emailLabel: 'Email', + passwordLabel: 'Password', + submitLabel: 'Register', + loginLabel: 'Sign in', + confirmPasswordLabel: 'Confirm Password', + confirmPassword: true, + disablePasswordValidation: false, + hideInputs: false, + pageLabel: 'Register', + loginPath: '/login', + loginCTA: 'Already have an account?', + requiredFieldErrorMessage: 'Cannot be empty', + networkErrorMessage: 'Network request failed.', + passwordMismatchErrorMessage: 'Passwords do not match.', + invalidEmailErrorMessage: 'Must be a valid email address', + formDisabledErrorMessage: 'Form disabled', + meetsRequirementsText: 'Password has met all requirements', + doesNotMeetRequirementsText: 'Password must meet the following requirements:', + minErrorText: 'be a minimum of 8 characters', + uppercaseErrorText: 'contain at least 1 uppercase character', + lowercaseErrorText: 'contain at least 1 lowercase character', + hasErrorText: 'contain at least 1 number or symbol', + formKey: 'portal-form', + googleButtonText: 'signup_with', + } as const; + + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return the initial base state', () => { + const result = usePortalGoogleRegistrationForm(props as any); + + expect(result.mode).toBe('base'); + expect(result.baseEmail).toBe(''); + expect(result.firstName).toBe(''); + expect(result.lastName).toBe(''); + expect(result.email).toBe(''); + expect(result.password).toBe(''); + expect(result.confirmPasswordValue).toBe(''); + expect(result.validationErrors).toEqual({}); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + expect(result.passwordHelpText).toContain(props.doesNotMeetRequirementsText); + }); + + it('should validate an empty email on base submit', async () => { + const result = usePortalGoogleRegistrationForm(props as any); + const event = createEvent(); + + await result.onBaseSubmit(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(universalHooksMock.setters[7]).toHaveBeenCalledWith({ email: 'Cannot be empty' }); + }); + + it('should validate an invalid email on base submit', async () => { + universalHooksMock.values.push(undefined, 'invalid-email'); + const result = usePortalGoogleRegistrationForm(props as any); + + await result.onBaseSubmit(createEvent()); + + expect(universalHooksMock.setters[7]).toHaveBeenCalledWith({ + email: 'Must be a valid email address', + }); + }); + + it('should transition to manual mode when the base form is valid', async () => { + universalHooksMock.values.push(undefined, 'user@example.com'); + const result = usePortalGoogleRegistrationForm(props as any); + + await result.onBaseSubmit(createEvent()); + + expect(universalHooksMock.setters[7]).toHaveBeenCalledWith({}); + expect(universalHooksMock.setters[4]).toHaveBeenCalledWith('user@example.com'); + expect(universalHooksMock.setters[8]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith('manual'); + }); + + it('should decode google credentials and transition to google mode', () => { + const payload = JSON.stringify({ + email: 'google@example.com', + given_name: 'Ada', + family_name: 'Lovelace', + }); + const base64Payload = btoa(payload).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + const credential = `header.${base64Payload}.signature`; + const result = usePortalGoogleRegistrationForm(props as any); + + result.onGoogleInit(new CustomEvent('init-complete', { detail: { credential } })); + + expect(universalHooksMock.setters[10]).toHaveBeenCalledWith(credential); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith('google@example.com'); + expect(universalHooksMock.setters[4]).toHaveBeenCalledWith('google@example.com'); + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('Ada'); + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith('Lovelace'); + expect(universalHooksMock.setters[7]).toHaveBeenCalledWith({}); + expect(universalHooksMock.setters[8]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith('google'); + }); + + it('should dispatch a navigate event for login', () => { + const dispatchSpy = document.dispatchEvent as ReturnType; + const result = usePortalGoogleRegistrationForm(props as any); + + result.onLogin(); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:navigate'); + expect(event.detail).toEqual({ path: '/login' }); + }); + + it('should validate required fields on submit', async () => { + const result = usePortalGoogleRegistrationForm(props as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[8]).toHaveBeenNthCalledWith(1, ''); + expect(universalHooksMock.setters[8]).toHaveBeenNthCalledWith(2, 'Cannot be empty'); + }); + + it('should validate password requirements on submit', async () => { + universalHooksMock.values.push(undefined, '', '', '', 'user@example.com', 'short', 'short'); + const result = usePortalGoogleRegistrationForm(props as any); + + await result.onSubmit(createEvent()); + + expect(result.passwordHelpText).toContain(props.minErrorText); + expect(result.passwordHelpText).toContain(props.uppercaseErrorText); + expect(result.passwordHelpText).toContain(props.lowercaseErrorText); + expect(result.passwordHelpText).toContain(props.hasErrorText); + expect(universalHooksMock.setters[8]).toHaveBeenNthCalledWith(2, result.passwordHelpText); + }); + + it('should dispatch the submit event when the manual form is valid', async () => { + universalHooksMock.values.push( + 'manual', + 'user@example.com', + 'Ada', + 'Lovelace', + 'user@example.com', + 'Valid123!', + 'Valid123!' + ); + const dispatchSpy = document.dispatchEvent as ReturnType; + const result = usePortalGoogleRegistrationForm(props as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[9]).toHaveBeenNthCalledWith(1, true); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:portal-google-registration-submit'); + expect(event.detail).toEqual({ + mode: 'manual', + formKey: 'portal-form', + nextPage: '/next', + redirectPath: '/verify-email', + email: 'user@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + password: 'Valid123!', + googleCredential: undefined, + }); + expect(universalHooksMock.setters[9]).toHaveBeenNthCalledWith(2, false); + }); +}); diff --git a/packages/lit-components/src/components/PortalGoogleRegistrationForm/usePortalGoogleRegistrationForm.ts b/packages/lit-components/src/components/PortalGoogleRegistrationForm/usePortalGoogleRegistrationForm.ts new file mode 100644 index 0000000000..5243767dcb --- /dev/null +++ b/packages/lit-components/src/components/PortalGoogleRegistrationForm/usePortalGoogleRegistrationForm.ts @@ -0,0 +1,299 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { PortalGoogleRegistrationFormProps } from './PortalGoogleRegistrationForm'; + +export type RegistrationMode = 'base' | 'manual' | 'google'; + +interface GoogleCredentialResponse { + credential?: string | null; +} + +function decodeJwtPayload(credential?: string | null) { + if (!credential) { + return undefined; + } + + const parts = credential.split('.'); + if (parts.length < 2) { + return undefined; + } + + try { + const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)); + const json = atob(normalized + padding); + return JSON.parse(json) as { + email?: string; + given_name?: string; + family_name?: string; + }; + } catch { + return undefined; + } +} + +function buildPasswordHelpText(props: PortalGoogleRegistrationFormProps, password: string) { + if (props.disablePasswordValidation) { + return ''; + } + + const checks = [ + password.length >= 8, + /[A-Z]/.test(password), + /[a-z]/.test(password), + /[^A-Za-z]/.test(password), + ]; + + return checks.every(Boolean) + ? props.meetsRequirementsText + : `${props.doesNotMeetRequirementsText} ${props.minErrorText}, ${props.uppercaseErrorText}, ${props.lowercaseErrorText}, ${props.hasErrorText}`; +} + +function isPasswordValid(props: PortalGoogleRegistrationFormProps, password: string) { + if (props.disablePasswordValidation) { + return Boolean(password); + } + + return ( + password.length >= 8 && + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /[^A-Za-z]/.test(password) + ); +} + +export interface PortalGoogleRegistrationFormHookResult { + mode: RegistrationMode; + baseEmail: string; + setBaseEmail: (value: string) => void; + firstName: string; + setFirstName: (value: string) => void; + lastName: string; + setLastName: (value: string) => void; + email: string; + setEmail: (value: string) => void; + password: string; + setPassword: (value: string) => void; + confirmPasswordValue: string; + setConfirmPasswordValue: (value: string) => void; + validationErrors: Record; + loading: boolean; + error: string; + passwordHelpText: string; + onBaseSubmit: (event: Event) => Promise; + onGoogleInit: (event: Event) => void; + onSubmit: (event: Event) => Promise; + onLogin: () => void; +} + +export function usePortalGoogleRegistrationForm( + props: PortalGoogleRegistrationFormProps +): PortalGoogleRegistrationFormHookResult { + const [mode, setMode] = useState('base'); + const [baseEmail, setBaseEmail] = useState(''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPasswordValue, setConfirmPasswordValue] = useState(''); + const [validationErrors, setValidationErrors] = useState>({}); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [googleCredential, setGoogleCredential] = useState(undefined); + + const passwordHelpText = buildPasswordHelpText(props, password); + + async function onBaseSubmit(event: Event) { + event.preventDefault(); + + if (!baseEmail) { + setValidationErrors({ email: props.requiredFieldErrorMessage }); + return; + } + + if (!baseEmail.includes('@')) { + setValidationErrors({ email: props.invalidEmailErrorMessage }); + return; + } + + setValidationErrors({}); + setEmail(baseEmail); + setError(''); + setMode('manual'); + } + + function onGoogleInit(event: Event) { + const detail = (event as CustomEvent).detail; + const credential = detail?.credential ?? undefined; + const payload = decodeJwtPayload(credential); + + setGoogleCredential(credential || undefined); + setBaseEmail(payload?.email || baseEmail); + setEmail(payload?.email || baseEmail); + setFirstName(payload?.given_name || ''); + setLastName(payload?.family_name || ''); + setValidationErrors({}); + setError(''); + setMode('google'); + } + + async function onSubmit(event: Event) { + event.preventDefault(); + setError(''); + + if (showRequiredFieldError()) { + return; + } + + if (mode !== 'google') { + if (props.confirmPassword && password !== confirmPasswordValue) { + setError(props.passwordMismatchErrorMessage); + return; + } + + if (!isPasswordValid(props, password)) { + setError(passwordHelpText || props.networkErrorMessage); + return; + } + } + + setLoading(true); + document.dispatchEvent( + new CustomEvent('sq:portal-google-registration-submit', { + bubbles: true, + composed: true, + detail: { + mode, + formKey: props.formKey, + nextPage: props.nextPage, + redirectPath: props.redirectPath, + email, + firstName, + lastName, + password: mode === 'google' ? undefined : password, + googleCredential, + }, + }) + ); + setLoading(false); + } + + function showRequiredFieldError() { + if (!props.hideInputs) { + if (!email) { + setError(props.requiredFieldErrorMessage); + return true; + } + + if (!email.includes('@')) { + setError(props.invalidEmailErrorMessage); + return true; + } + } + + if (mode !== 'google' && !password) { + setError(props.requiredFieldErrorMessage); + return true; + } + + return false; + } + + function onLogin() { + document.dispatchEvent( + new CustomEvent('sq:navigate', { + bubbles: true, + composed: true, + detail: { path: props.loginPath }, + }) + ); + } + + return { + mode, + baseEmail, + setBaseEmail, + firstName, + setFirstName, + lastName, + setLastName, + email, + setEmail, + password, + setPassword, + confirmPasswordValue, + setConfirmPasswordValue, + validationErrors, + loading, + error, + passwordHelpText, + onBaseSubmit, + onGoogleInit, + onSubmit, + onLogin, + }; +} + +export function useDemoPortalGoogleRegistrationForm( + props: PortalGoogleRegistrationFormProps +): PortalGoogleRegistrationFormHookResult { + const [mode, setMode] = useState('manual'); + const [baseEmail, setBaseEmail] = useState('jane@example.com'); + const [firstName, setFirstName] = useState('Jane'); + const [lastName, setLastName] = useState('Doe'); + const [email, setEmail] = useState('jane@example.com'); + const [password, setPassword] = useState('Password123!'); + const [confirmPasswordValue, setConfirmPasswordValue] = useState('Password123!'); + const [validationErrors, setValidationErrors] = useState>({}); + const [error, setError] = useState(''); + const [loading] = useState(false); + + const passwordHelpText = buildPasswordHelpText(props, password); + + async function onBaseSubmit(event: Event) { + event.preventDefault(); + setValidationErrors({}); + setEmail(baseEmail); + setError(''); + setMode('manual'); + } + + function onGoogleInit(_event: Event) { + setValidationErrors({}); + setError(''); + setMode('google'); + } + + async function onSubmit(event: Event) { + event.preventDefault(); + setValidationErrors({}); + setError(''); + } + + function onLogin() { + setError(''); + } + + return { + mode, + baseEmail, + setBaseEmail, + firstName, + setFirstName, + lastName, + setLastName, + email, + setEmail, + password, + setPassword, + confirmPasswordValue: props.confirmPassword ? confirmPasswordValue : '', + setConfirmPasswordValue, + validationErrors, + loading, + error, + passwordHelpText, + onBaseSubmit, + onGoogleInit, + onSubmit, + onLogin, + }; +} diff --git a/packages/lit-components/src/components/PortalLogin/PortalLogin.stories.ts b/packages/lit-components/src/components/PortalLogin/PortalLogin.stories.ts new file mode 100644 index 0000000000..37baa511ab --- /dev/null +++ b/packages/lit-components/src/components/PortalLogin/PortalLogin.stories.ts @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { PortalLoginView } from './PortalLoginView'; + +const meta: Meta = { + title: 'Components/PortalLogin', + component: 'sql-portal-login', + tags: ['autodocs'], + argTypes: { + 'email-label': { control: 'text' }, + 'password-label': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'forgot-password-label': { control: 'text' }, + 'register-label': { control: 'text' }, + 'header-text': { control: 'text' }, + 'error-message': { control: 'text' }, + 'hide-register': { control: 'boolean' }, + 'hide-forgot-password': { control: 'boolean' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + emailLabel: 'Email', + passwordLabel: 'Password', + submitLabel: 'Sign In', + forgotPasswordLabel: 'Forgot Password?', + registerLabel: 'Register', + headerText: 'Sign In', + hideRegister: false, + hideForgotPassword: false, + email: 'jane@example.com', + setEmail: () => undefined, + password: 'password123', + setPassword: () => undefined, + error: '', + loading: false, + onSubmit: (event: Event) => event.preventDefault(), + onForgotPassword: () => undefined, + onRegister: () => undefined, +}; + +export const Default: Story = {}; + +export const WithError: Story = { + args: { + 'error-message': 'Invalid email or password.', + }, +}; + +export const HideRegister: Story = { + args: { + 'hide-register': true, + }, +}; + +export const HideForgotPassword: Story = { + args: { + 'hide-forgot-password': true, + }, +}; + +export const CustomLabels: Story = { + args: { + 'email-label': 'Work Email', + 'password-label': 'Account Password', + 'submit-label': 'Continue', + 'forgot-password-label': 'Need help signing in?', + 'register-label': 'Create an account', + 'header-text': 'Welcome back', + }, +}; + +export const Loading: Story = { + render: () => + PortalLoginView({ + ...baseProps, + loading: true, + }), +}; diff --git a/packages/lit-components/src/components/PortalLogin/PortalLogin.ts b/packages/lit-components/src/components/PortalLogin/PortalLogin.ts new file mode 100644 index 0000000000..e65df477f1 --- /dev/null +++ b/packages/lit-components/src/components/PortalLogin/PortalLogin.ts @@ -0,0 +1,63 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalLoginView } from './PortalLoginView'; +import { useDemoPortalLogin, usePortalLogin } from './usePortalLogin'; + +export interface PortalLoginProps { + emailLabel: string; + passwordLabel: string; + submitLabel: string; + forgotPasswordLabel: string; + registerLabel: string; + headerText: string; + errorMessage?: string; + hideRegister: boolean; + hideForgotPassword: boolean; +} + +const parseBoolean = (value: unknown, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-login': HTMLElement; + } +} + +export const PortalLogin = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PortalLoginProps = { + emailLabel: typeof rawProps.emailLabel === 'string' ? rawProps.emailLabel : 'Email', + passwordLabel: typeof rawProps.passwordLabel === 'string' ? rawProps.passwordLabel : 'Password', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Sign In', + forgotPasswordLabel: + typeof rawProps.forgotPasswordLabel === 'string' ? rawProps.forgotPasswordLabel : 'Forgot Password?', + registerLabel: typeof rawProps.registerLabel === 'string' ? rawProps.registerLabel : 'Register', + headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Sign In', + errorMessage: typeof rawProps.errorMessage === 'string' ? rawProps.errorMessage : undefined, + hideRegister: parseBoolean(rawProps.hideRegister, false), + hideForgotPassword: parseBoolean(rawProps.hideForgotPassword, false), + }; + + const hookProps = isDemo() ? useDemoPortalLogin(props) : usePortalLogin(props); + + return PortalLoginView({ ...props, ...hookProps }); + }, + 'sql-portal-login', + [ + 'email-label', + 'password-label', + 'submit-label', + 'forgot-password-label', + 'register-label', + 'header-text', + 'error-message', + 'hide-register', + 'hide-forgot-password', + ] as const +); diff --git a/packages/lit-components/src/components/PortalLogin/PortalLoginView.ts b/packages/lit-components/src/components/PortalLogin/PortalLoginView.ts new file mode 100644 index 0000000000..cb5b655b11 --- /dev/null +++ b/packages/lit-components/src/components/PortalLogin/PortalLoginView.ts @@ -0,0 +1,91 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PortalLoginProps } from './PortalLogin'; +import { usePortalLogin } from './usePortalLogin'; + +const getInputValue = (event: Event) => ((event.target as HTMLInputElement & { value: string })?.value ?? ''); + +export function PortalLoginView(props: PortalLoginProps & ReturnType) { + const errorMessage = props.error || props.errorMessage; + + return html` + + + `; +} diff --git a/packages/lit-components/src/components/PortalLogin/index.ts b/packages/lit-components/src/components/PortalLogin/index.ts new file mode 100644 index 0000000000..beac3f5c9d --- /dev/null +++ b/packages/lit-components/src/components/PortalLogin/index.ts @@ -0,0 +1 @@ +export * from './PortalLogin'; diff --git a/packages/lit-components/src/components/PortalLogin/usePortalLogin.test.ts b/packages/lit-components/src/components/PortalLogin/usePortalLogin.test.ts new file mode 100644 index 0000000000..ebad64578b --- /dev/null +++ b/packages/lit-components/src/components/PortalLogin/usePortalLogin.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + isDemo: vi.fn(() => false), + useMutation: vi.fn(() => [vi.fn()]), + useUserIdentity: vi.fn(() => null), +})); + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePortalLogin } from './usePortalLogin'; + +describe('usePortalLogin', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + universalHooksMock.useState.mockClear(); + componentBoilerplateMock.isDemo.mockClear(); + componentBoilerplateMock.useMutation.mockClear(); + componentBoilerplateMock.useUserIdentity.mockClear(); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = usePortalLogin({} as any); + + expect(result.email).toBe(''); + expect(result.password).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + }); + + it('should validate empty fields', async () => { + const result = usePortalLogin({} as any); + const event = createEvent() as any; + + await result.onSubmit(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('Please fill in all fields'); + }); + + it('should dispatch a login event on submit', async () => { + universalHooksMock.values.push('test@example.com', 'secret-password'); + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalLogin({} as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[3]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[3]).toHaveBeenNthCalledWith(2, false); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:login'); + expect(event.detail).toEqual({ email: 'test@example.com', password: 'secret-password' }); + }); + + it('should navigate to forgot password', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalLogin({} as any); + + result.onForgotPassword(); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:navigate'); + expect(event.detail).toEqual({ path: '/forgot-password' }); + }); + + it('should navigate to register', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalLogin({} as any); + + result.onRegister(); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:navigate'); + expect(event.detail).toEqual({ path: '/register' }); + }); +}); diff --git a/packages/lit-components/src/components/PortalLogin/usePortalLogin.ts b/packages/lit-components/src/components/PortalLogin/usePortalLogin.ts new file mode 100644 index 0000000000..53329d6181 --- /dev/null +++ b/packages/lit-components/src/components/PortalLogin/usePortalLogin.ts @@ -0,0 +1,109 @@ +import { useMutation, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { PortalLoginProps } from './PortalLogin'; + +const LOGIN_MUTATION = gql` + mutation login($email: String!, $password: String!) { + login(email: $email, password: $password) { + token + user { + id + accountId + } + } + } +`; + +export function usePortalLogin(props: PortalLoginProps) { + const [loginMutation] = useMutation(LOGIN_MUTATION); + const user = useUserIdentity(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + void loginMutation; + void user; + void props; + + async function onSubmit(event: Event) { + event.preventDefault(); + if (!email || !password) { + setError('Please fill in all fields'); + return; + } + setLoading(true); + setError(''); + document.dispatchEvent( + new CustomEvent('sq:login', { + bubbles: true, + composed: true, + detail: { email, password }, + }) + ); + setLoading(false); + } + + function onForgotPassword() { + document.dispatchEvent( + new CustomEvent('sq:navigate', { + bubbles: true, + composed: true, + detail: { path: '/forgot-password' }, + }) + ); + } + + function onRegister() { + document.dispatchEvent( + new CustomEvent('sq:navigate', { + bubbles: true, + composed: true, + detail: { path: '/register' }, + }) + ); + } + + return { + email, + setEmail, + password, + setPassword, + error, + loading, + onSubmit, + onForgotPassword, + onRegister, + }; +} + +export function useDemoPortalLogin(_props: PortalLoginProps): ReturnType { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + async function onSubmit(event: Event) { + event.preventDefault(); + console.log('Demo login submit'); + } + + function onForgotPassword() { + console.log('Demo forgot password'); + } + + function onRegister() { + console.log('Demo register'); + } + + return { + email, + setEmail, + password, + setPassword, + error: '', + loading: false, + onSubmit, + onForgotPassword, + onRegister, + }; +} diff --git a/packages/lit-components/src/components/PortalLogout/PortalLogout.stories.ts b/packages/lit-components/src/components/PortalLogout/PortalLogout.stories.ts new file mode 100644 index 0000000000..a163b63aea --- /dev/null +++ b/packages/lit-components/src/components/PortalLogout/PortalLogout.stories.ts @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/PortalLogout', + component: 'sql-portal-logout', + tags: ['autodocs'], + render: () => html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/lit-components/src/components/PortalLogout/PortalLogout.ts b/packages/lit-components/src/components/PortalLogout/PortalLogout.ts new file mode 100644 index 0000000000..f29d76e1c1 --- /dev/null +++ b/packages/lit-components/src/components/PortalLogout/PortalLogout.ts @@ -0,0 +1,30 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalLogoutView } from './PortalLogoutView'; +import { useDemoPortalLogout, usePortalLogout } from './usePortalLogout'; + +export interface PortalLogoutProps { + redirectUrl: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-logout': HTMLElement; + } +} + +export const PortalLogout = useComponent( + (host) => { + const rawProps = getProps(host); + const props: PortalLogoutProps = { + redirectUrl: rawProps.redirectUrl || '/login', + }; + + isDemo() ? useDemoPortalLogout(props) : usePortalLogout(props); + + return PortalLogoutView(); + }, + 'sql-portal-logout', + ['redirect-url'] as const +); diff --git a/packages/lit-components/src/components/PortalLogout/PortalLogoutView.ts b/packages/lit-components/src/components/PortalLogout/PortalLogoutView.ts new file mode 100644 index 0000000000..f623b140ef --- /dev/null +++ b/packages/lit-components/src/components/PortalLogout/PortalLogoutView.ts @@ -0,0 +1,11 @@ +import { html } from 'lit'; + +export function PortalLogoutView() { + return html` + + `; +} diff --git a/packages/lit-components/src/components/PortalLogout/index.ts b/packages/lit-components/src/components/PortalLogout/index.ts new file mode 100644 index 0000000000..be14ee2a17 --- /dev/null +++ b/packages/lit-components/src/components/PortalLogout/index.ts @@ -0,0 +1 @@ +export * from './PortalLogout'; diff --git a/packages/lit-components/src/components/PortalLogout/usePortalLogout.test.ts b/packages/lit-components/src/components/PortalLogout/usePortalLogout.test.ts new file mode 100644 index 0000000000..e1976f3c55 --- /dev/null +++ b/packages/lit-components/src/components/PortalLogout/usePortalLogout.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { usePortalLogout } from './usePortalLogout'; + +describe('usePortalLogout', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + vi.stubGlobal('window', { location: { hash: '' } }); + }); + + it('should return a logout function', () => { + const result = usePortalLogout({} as any); + + expect(typeof result.logout).toBe('function'); + }); + + it('should dispatch logout when the hook is used and when logout is called', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalLogout({} as any); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect((dispatchSpy.mock.calls[0][0] as CustomEvent).type).toBe('sq:logout'); + + result.logout(); + + expect(dispatchSpy).toHaveBeenCalledTimes(2); + expect((dispatchSpy.mock.calls[1][0] as CustomEvent).type).toBe('sq:logout'); + }); + + it('should set the hash when redirectUrl is provided', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + + usePortalLogout({ redirectUrl: '/login' } as any); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect((dispatchSpy.mock.calls[0][0] as CustomEvent).type).toBe('sq:logout'); + expect(window.location.hash).toBe('/login'); + }); +}); diff --git a/packages/lit-components/src/components/PortalLogout/usePortalLogout.ts b/packages/lit-components/src/components/PortalLogout/usePortalLogout.ts new file mode 100644 index 0000000000..575d8c35e4 --- /dev/null +++ b/packages/lit-components/src/components/PortalLogout/usePortalLogout.ts @@ -0,0 +1,20 @@ +import type { PortalLogoutProps } from './PortalLogout'; + +export function usePortalLogout(props: PortalLogoutProps) { + function logout() { + const event = new CustomEvent('sq:logout', { bubbles: true, composed: true }); + document.dispatchEvent(event); + if (props.redirectUrl) { + window.location.hash = props.redirectUrl; + } + } + + logout(); + return { logout }; +} + +export function useDemoPortalLogout(_props: PortalLogoutProps): ReturnType { + return { + logout: () => undefined, + }; +} diff --git a/packages/lit-components/src/components/PortalProfile/PortalProfile.stories.ts b/packages/lit-components/src/components/PortalProfile/PortalProfile.stories.ts new file mode 100644 index 0000000000..fb050fd9c5 --- /dev/null +++ b/packages/lit-components/src/components/PortalProfile/PortalProfile.stories.ts @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { PortalProfileView } from './PortalProfileView'; + +const meta: Meta = { + title: 'Components/PortalProfile', + component: 'sql-portal-profile', + tags: ['autodocs'], + argTypes: { + 'first-name-label': { control: 'text' }, + 'last-name-label': { control: 'text' }, + 'email-label': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'header-text': { control: 'text' }, + 'show-country': { control: 'boolean' }, + 'country-label': { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + firstNameLabel: 'First Name', + lastNameLabel: 'Last Name', + emailLabel: 'Email', + submitLabel: 'Save Changes', + headerText: 'Profile', + showCountry: false, + countryLabel: 'Country', + firstName: 'Jane', + setFirstName: () => undefined, + lastName: 'Doe', + setLastName: () => undefined, + email: 'jane@example.com', + setEmail: () => undefined, + country: 'US', + setCountry: () => undefined, + error: '', + success: false, + loading: false, + saving: false, + onSubmit: (event: Event) => event.preventDefault(), +}; + +export const Default: Story = { + render: () => PortalProfileView(baseProps), +}; + +export const Loading: Story = { + render: () => + PortalProfileView({ + ...baseProps, + loading: true, + }), +}; + +export const WithCountry: Story = { + render: () => + PortalProfileView({ + ...baseProps, + showCountry: true, + }), +}; + +export const WithError: Story = { + render: () => + PortalProfileView({ + ...baseProps, + error: 'Unable to save changes right now.', + }), +}; + +export const Success: Story = { + render: () => + PortalProfileView({ + ...baseProps, + success: true, + }), +}; diff --git a/packages/lit-components/src/components/PortalProfile/PortalProfile.ts b/packages/lit-components/src/components/PortalProfile/PortalProfile.ts new file mode 100644 index 0000000000..1c26c0a2b8 --- /dev/null +++ b/packages/lit-components/src/components/PortalProfile/PortalProfile.ts @@ -0,0 +1,48 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalProfileView } from './PortalProfileView'; +import { useDemoPortalProfile, usePortalProfile } from './usePortalProfile'; + +export interface PortalProfileProps { + firstNameLabel: string; + lastNameLabel: string; + emailLabel: string; + submitLabel: string; + headerText: string; + showCountry: boolean; + countryLabel: string; +} + +const parseBoolean = (value: unknown, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-profile': HTMLElement; + } +} + +export const PortalProfile = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PortalProfileProps = { + firstNameLabel: typeof rawProps.firstNameLabel === 'string' ? rawProps.firstNameLabel : 'First Name', + lastNameLabel: typeof rawProps.lastNameLabel === 'string' ? rawProps.lastNameLabel : 'Last Name', + emailLabel: typeof rawProps.emailLabel === 'string' ? rawProps.emailLabel : 'Email', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Save Changes', + headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Profile', + showCountry: parseBoolean(rawProps.showCountry, false), + countryLabel: typeof rawProps.countryLabel === 'string' ? rawProps.countryLabel : 'Country', + }; + + const hookProps = isDemo() ? useDemoPortalProfile(props) : usePortalProfile(props); + + return PortalProfileView({ ...props, ...hookProps }); + }, + 'sql-portal-profile', + ['first-name-label', 'last-name-label', 'email-label', 'submit-label', 'header-text', 'show-country', 'country-label'] as const +); diff --git a/packages/lit-components/src/components/PortalProfile/PortalProfileView.ts b/packages/lit-components/src/components/PortalProfile/PortalProfileView.ts new file mode 100644 index 0000000000..80f0be6c5b --- /dev/null +++ b/packages/lit-components/src/components/PortalProfile/PortalProfileView.ts @@ -0,0 +1,60 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PortalProfileProps } from './PortalProfile'; +import { usePortalProfile } from './usePortalProfile'; + +const getInputValue = (event: Event) => ((event.target as HTMLInputElement & { value: string })?.value ?? ''); + +export function PortalProfileView(props: PortalProfileProps & ReturnType) { + return html` + +
+

${props.headerText}

+ ${props.loading ? html`${UI.Alert({ variant: 'primary', open: true, children: 'Loading profile...' })}` : ''} + ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} + ${props.success ? html`${UI.Alert({ variant: 'success', open: true, children: 'Profile updated successfully.' })}` : ''} +
+
+ ${UI.Input({ label: props.firstNameLabel, value: props.firstName, onInput: (event: Event) => props.setFirstName(getInputValue(event)), disabled: props.loading || props.saving })} + ${UI.Input({ label: props.lastNameLabel, value: props.lastName, onInput: (event: Event) => props.setLastName(getInputValue(event)), disabled: props.loading || props.saving })} +
+ ${UI.Input({ label: props.emailLabel, type: 'email', value: props.email, onInput: (event: Event) => props.setEmail(getInputValue(event)), disabled: props.loading || props.saving })} + ${props.showCountry + ? html`${UI.Input({ label: props.countryLabel, value: props.country, onInput: (event: Event) => props.setCountry(getInputValue(event)), disabled: props.loading || props.saving })}` + : ''} + ${UI.Button({ type: 'submit', variant: 'primary', loading: props.saving, disabled: props.loading, children: props.submitLabel })} +
+
+ `; +} diff --git a/packages/lit-components/src/components/PortalProfile/index.ts b/packages/lit-components/src/components/PortalProfile/index.ts new file mode 100644 index 0000000000..22a02ee995 --- /dev/null +++ b/packages/lit-components/src/components/PortalProfile/index.ts @@ -0,0 +1 @@ +export * from './PortalProfile'; diff --git a/packages/lit-components/src/components/PortalProfile/usePortalProfile.test.ts b/packages/lit-components/src/components/PortalProfile/usePortalProfile.test.ts new file mode 100644 index 0000000000..e2e8871b93 --- /dev/null +++ b/packages/lit-components/src/components/PortalProfile/usePortalProfile.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + isDemo: vi.fn(() => false), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { user: { firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', countryCode: 'US' } }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => ({ + setters: [] as Array>, + stateValues: [] as unknown[], + useEffect: vi.fn((effect: () => void) => effect()), + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + universalHooksMock.setters.push(setter); + const value = universalHooksMock.stateValues.length ? universalHooksMock.stateValues.shift() : initial; + return [value, setter] as const; + }), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useEffect: universalHooksMock.useEffect, + useState: universalHooksMock.useState, +})); + +import { usePortalProfile } from './usePortalProfile'; + +describe('usePortalProfile', () => { + beforeEach(() => { + if (!globalThis.document) { + Object.defineProperty(globalThis, 'document', { + value: { dispatchEvent: () => true }, + configurable: true, + writable: true, + }); + } + if (!globalThis.CustomEvent) { + Object.defineProperty(globalThis, 'CustomEvent', { + value: class { + type: string; + detail: unknown; + constructor(type: string, init?: { detail?: unknown }) { + this.type = type; + this.detail = init?.detail; + } + }, + configurable: true, + }); + } + universalHooksMock.setters.length = 0; + universalHooksMock.stateValues.splice(0, universalHooksMock.stateValues.length, 'Jane', 'Doe', 'jane@example.com', 'US', '', false, false); + componentBoilerplateMock.isDemo.mockReturnValue(false); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', countryCode: 'US' } }, + loading: false, + }); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + universalHooksMock.stateValues.splice(0, universalHooksMock.stateValues.length, '', '', '', '', '', false, false); + + const result = usePortalProfile({} as any); + + expect(result.loading).toBe(true); + }); + + it('dispatches a profile update event on submit', async () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent').mockReturnValue(true); + const result = usePortalProfile({} as any); + const preventDefault = vi.fn(); + + await result.onSubmit({ preventDefault } as any); + + expect(preventDefault).toHaveBeenCalled(); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0]?.[0] as any; + expect(event.type).toBe('sq:profile-update'); + expect(event.detail).toEqual({ + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@example.com', + country: 'US', + }); + }); + + it('returns form state values', () => { + const result = usePortalProfile({} as any); + + expect(result.firstName).toBe('Jane'); + expect(result.lastName).toBe('Doe'); + expect(result.email).toBe('jane@example.com'); + expect(result.country).toBe('US'); + }); +}); diff --git a/packages/lit-components/src/components/PortalProfile/usePortalProfile.ts b/packages/lit-components/src/components/PortalProfile/usePortalProfile.ts new file mode 100644 index 0000000000..bc54fbc11f --- /dev/null +++ b/packages/lit-components/src/components/PortalProfile/usePortalProfile.ts @@ -0,0 +1,109 @@ +import { useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useEffect, useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { PortalProfileProps } from './PortalProfile'; + +const PROFILE_QUERY = gql` + query getProfile { + user: viewer { + ... on User { + firstName + lastName + email + countryCode + } + } + } +`; + +export function usePortalProfile(props: PortalProfileProps) { + const user = useUserIdentity(); + const shouldSkipQuery = !user?.jwt; + const { data, loading } = useQuery(PROFILE_QUERY, {}, shouldSkipQuery); + + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [email, setEmail] = useState(''); + const [country, setCountry] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [saving, setSaving] = useState(false); + + void props; + + useEffect(() => { + if (!data?.user) { + return; + } + setFirstName(data.user.firstName || ''); + setLastName(data.user.lastName || ''); + setEmail(data.user.email || ''); + setCountry(data.user.countryCode || ''); + }, [data?.user?.firstName, data?.user?.lastName, data?.user?.email, data?.user?.countryCode]); + + async function onSubmit(event: Event) { + event.preventDefault(); + setSaving(true); + setError(''); + setSuccess(false); + document.dispatchEvent( + new CustomEvent('sq:profile-update', { + bubbles: true, + composed: true, + detail: { firstName, lastName, email, country }, + }) + ); + setSuccess(true); + setSaving(false); + } + + return { + firstName, + setFirstName, + lastName, + setLastName, + email, + setEmail, + country, + setCountry, + error, + success, + loading, + saving, + onSubmit, + }; +} + +export function useDemoPortalProfile(_props: PortalProfileProps): ReturnType { + const [firstName, setFirstName] = useState('Joe'); + const [lastName, setLastName] = useState('Smith'); + const [email, setEmail] = useState('jsmith@example.com'); + const [country, setCountry] = useState('CA'); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [saving, setSaving] = useState(false); + + async function onSubmit(event: Event) { + event.preventDefault(); + setSaving(true); + setError(''); + setSuccess(true); + setSaving(false); + } + + return { + firstName, + setFirstName, + lastName, + setLastName, + email, + setEmail, + country, + setCountry, + error, + success, + loading: false, + saving, + onSubmit, + }; +} diff --git a/packages/lit-components/src/components/PortalProtectedRoute/PortalProtectedRoute.stories.ts b/packages/lit-components/src/components/PortalProtectedRoute/PortalProtectedRoute.stories.ts new file mode 100644 index 0000000000..d3a157884d --- /dev/null +++ b/packages/lit-components/src/components/PortalProtectedRoute/PortalProtectedRoute.stories.ts @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const setAuthGlobals = (authenticated: boolean) => { + if (authenticated) { + // @ts-expect-error story globals + window.squatchToken = 'demo-token'; + // @ts-expect-error story globals + window.widgetIdent = { token: 'demo-token' }; + } else { + // @ts-expect-error story globals + delete window.squatchToken; + // @ts-expect-error story globals + window.widgetIdent = {}; + } +}; + +const meta: Meta = { + title: 'Components/PortalProtectedRoute', + component: 'sql-portal-protected-route', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Authenticated: Story = { + render: () => { + setAuthGlobals(true); + return html` + +

Protected content

+
+ `; + }, +}; + +export const Unauthenticated: Story = { + render: () => { + setAuthGlobals(false); + return html` + +

Protected content

+
+ `; + }, +}; diff --git a/packages/lit-components/src/components/PortalProtectedRoute/PortalProtectedRoute.ts b/packages/lit-components/src/components/PortalProtectedRoute/PortalProtectedRoute.ts new file mode 100644 index 0000000000..f213eb582d --- /dev/null +++ b/packages/lit-components/src/components/PortalProtectedRoute/PortalProtectedRoute.ts @@ -0,0 +1,30 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalProtectedRouteView } from './PortalProtectedRouteView'; +import { useDemoPortalProtectedRoute, usePortalProtectedRoute } from './usePortalProtectedRoute'; + +export interface PortalProtectedRouteProps { + redirectUrl: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-protected-route': HTMLElement; + } +} + +export const PortalProtectedRoute = useComponent( + (host) => { + const rawProps = getProps(host); + const props: PortalProtectedRouteProps = { + redirectUrl: rawProps.redirectUrl || '/login', + }; + + const hookProps = isDemo() ? useDemoPortalProtectedRoute(props) : usePortalProtectedRoute(props); + + return PortalProtectedRouteView({ ...props, ...hookProps }); + }, + 'sql-portal-protected-route', + ['redirect-url'] as const +); diff --git a/packages/lit-components/src/components/PortalProtectedRoute/PortalProtectedRouteView.ts b/packages/lit-components/src/components/PortalProtectedRoute/PortalProtectedRouteView.ts new file mode 100644 index 0000000000..914756778a --- /dev/null +++ b/packages/lit-components/src/components/PortalProtectedRoute/PortalProtectedRouteView.ts @@ -0,0 +1,17 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { PortalProtectedRouteProps } from './PortalProtectedRoute'; +import { usePortalProtectedRoute } from './usePortalProtectedRoute'; + +export function PortalProtectedRouteView( + _props: PortalProtectedRouteProps & ReturnType +) { + return html` + + ${_props.isAuthenticated ? html`` : html`${UI.Spinner({})}`} + `; +} diff --git a/packages/lit-components/src/components/PortalProtectedRoute/index.ts b/packages/lit-components/src/components/PortalProtectedRoute/index.ts new file mode 100644 index 0000000000..45cb819b02 --- /dev/null +++ b/packages/lit-components/src/components/PortalProtectedRoute/index.ts @@ -0,0 +1 @@ +export * from './PortalProtectedRoute'; diff --git a/packages/lit-components/src/components/PortalProtectedRoute/usePortalProtectedRoute.test.ts b/packages/lit-components/src/components/PortalProtectedRoute/usePortalProtectedRoute.test.ts new file mode 100644 index 0000000000..2cb988da3d --- /dev/null +++ b/packages/lit-components/src/components/PortalProtectedRoute/usePortalProtectedRoute.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useProgramId: vi.fn(), + useQuery: vi.fn(), + useMutation: vi.fn(), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { usePortalProtectedRoute } from './usePortalProtectedRoute'; + +describe('usePortalProtectedRoute', () => { + beforeEach(() => { + vi.restoreAllMocks(); + componentBoilerplateMock.useUserIdentity.mockReset(); + componentBoilerplateMock.useUserIdentity.mockReturnValue({ jwt: 'test-jwt' }); + vi.stubGlobal('window', { location: { hash: '' } }); + }); + + it('should return authenticated when a jwt is present', () => { + const result = usePortalProtectedRoute({ redirectUrl: '/login' }); + + expect(result.isAuthenticated).toBe(true); + expect(window.location.hash).toBe(''); + }); + + it('should return unauthenticated when no jwt is present', () => { + componentBoilerplateMock.useUserIdentity.mockReturnValue(undefined); + + const result = usePortalProtectedRoute({ redirectUrl: '' }); + + expect(result.isAuthenticated).toBe(false); + }); + + it('should redirect when unauthenticated and redirectUrl is provided', () => { + componentBoilerplateMock.useUserIdentity.mockReturnValue(undefined); + + const result = usePortalProtectedRoute({ redirectUrl: '/login' }); + + expect(result.isAuthenticated).toBe(false); + expect(window.location.hash).toBe('/login'); + }); +}); diff --git a/packages/lit-components/src/components/PortalProtectedRoute/usePortalProtectedRoute.ts b/packages/lit-components/src/components/PortalProtectedRoute/usePortalProtectedRoute.ts new file mode 100644 index 0000000000..a5756ccc45 --- /dev/null +++ b/packages/lit-components/src/components/PortalProtectedRoute/usePortalProtectedRoute.ts @@ -0,0 +1,17 @@ +import { useUserIdentity } from '@saasquatch/component-boilerplate'; +import type { PortalProtectedRouteProps } from './PortalProtectedRoute'; + +export function usePortalProtectedRoute(props: PortalProtectedRouteProps) { + const user = useUserIdentity(); + const isAuthenticated = !!user?.jwt; + + if (!isAuthenticated && props.redirectUrl) { + window.location.hash = props.redirectUrl; + } + + return { isAuthenticated }; +} + +export function useDemoPortalProtectedRoute(_props: PortalProtectedRouteProps) { + return { isAuthenticated: true }; +} diff --git a/packages/lit-components/src/components/PortalRegister/PortalRegister.stories.ts b/packages/lit-components/src/components/PortalRegister/PortalRegister.stories.ts new file mode 100644 index 0000000000..6f9c35b98e --- /dev/null +++ b/packages/lit-components/src/components/PortalRegister/PortalRegister.stories.ts @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { PortalRegisterView } from './PortalRegisterView'; + +const meta: Meta = { + title: 'Components/PortalRegister', + component: 'sql-portal-register', + tags: ['autodocs'], + argTypes: { + 'email-label': { control: 'text' }, + 'password-label': { control: 'text' }, + 'confirm-password-label': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'login-label': { control: 'text' }, + 'header-text': { control: 'text' }, + 'show-confirm-password': { control: 'boolean' }, + 'password-min-length': { control: 'number' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + emailLabel: 'Email', + passwordLabel: 'Password', + confirmPasswordLabel: 'Confirm Password', + submitLabel: 'Register', + loginLabel: 'Already have an account? Sign in', + headerText: 'Create Account', + showConfirmPassword: true, + passwordMinLength: 8, + email: 'jane@example.com', + setEmail: () => undefined, + password: 'password123', + setPassword: () => undefined, + confirmPassword: 'password123', + setConfirmPassword: () => undefined, + error: '', + loading: false, + onSubmit: (event: Event) => event.preventDefault(), + onLogin: () => undefined, +}; + +export const Default: Story = {}; + +export const WithError: Story = { + render: () => + PortalRegisterView({ + ...baseProps, + error: 'Passwords do not match', + confirmPassword: 'different-password', + }), +}; + +export const NoConfirmPassword: Story = { + args: { + 'show-confirm-password': false, + }, +}; + +export const CustomLabels: Story = { + args: { + 'email-label': 'Work Email', + 'password-label': 'Create Password', + 'confirm-password-label': 'Repeat Password', + 'submit-label': 'Create Account', + 'login-label': 'Sign in instead', + 'header-text': 'Join the portal', + }, +}; + +export const Loading: Story = { + render: () => + PortalRegisterView({ + ...baseProps, + loading: true, + }), +}; diff --git a/packages/lit-components/src/components/PortalRegister/PortalRegister.ts b/packages/lit-components/src/components/PortalRegister/PortalRegister.ts new file mode 100644 index 0000000000..fcb1260a69 --- /dev/null +++ b/packages/lit-components/src/components/PortalRegister/PortalRegister.ts @@ -0,0 +1,67 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalRegisterView } from './PortalRegisterView'; +import { useDemoPortalRegister, usePortalRegister } from './usePortalRegister'; + +export interface PortalRegisterProps { + emailLabel: string; + passwordLabel: string; + confirmPasswordLabel: string; + submitLabel: string; + loginLabel: string; + headerText: string; + showConfirmPassword: boolean; + passwordMinLength: number; +} + +const parseBoolean = (value: unknown, fallback = false) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim() !== '') return Number(value); + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-register': HTMLElement; + } +} + +export const PortalRegister = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PortalRegisterProps = { + emailLabel: typeof rawProps.emailLabel === 'string' ? rawProps.emailLabel : 'Email', + passwordLabel: typeof rawProps.passwordLabel === 'string' ? rawProps.passwordLabel : 'Password', + confirmPasswordLabel: + typeof rawProps.confirmPasswordLabel === 'string' ? rawProps.confirmPasswordLabel : 'Confirm Password', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Register', + loginLabel: + typeof rawProps.loginLabel === 'string' ? rawProps.loginLabel : 'Already have an account? Sign in', + headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Create Account', + showConfirmPassword: parseBoolean(rawProps.showConfirmPassword, true), + passwordMinLength: parseNumber(rawProps.passwordMinLength, 8), + }; + + const hookProps = isDemo() ? useDemoPortalRegister(props) : usePortalRegister(props); + + return PortalRegisterView({ ...props, ...hookProps }); + }, + 'sql-portal-register', + [ + 'email-label', + 'password-label', + 'confirm-password-label', + 'submit-label', + 'login-label', + 'header-text', + 'show-confirm-password', + 'password-min-length', + ] as const +); diff --git a/packages/lit-components/src/components/PortalRegister/PortalRegisterView.ts b/packages/lit-components/src/components/PortalRegister/PortalRegisterView.ts new file mode 100644 index 0000000000..e91121bdd1 --- /dev/null +++ b/packages/lit-components/src/components/PortalRegister/PortalRegisterView.ts @@ -0,0 +1,71 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PortalRegisterProps } from './PortalRegister'; +import { usePortalRegister } from './usePortalRegister'; + +const getInputValue = (event: Event) => ((event.target as HTMLInputElement & { value: string })?.value ?? ''); + +export function PortalRegisterView(props: PortalRegisterProps & ReturnType) { + return html` + +
+

${props.headerText}

+ ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} +
+ ${UI.Input({ label: props.emailLabel, type: 'email', value: props.email, onInput: (event: Event) => props.setEmail(getInputValue(event)), required: true })} + ${UI.Input({ label: props.passwordLabel, type: 'password', value: props.password, onInput: (event: Event) => props.setPassword(getInputValue(event)), required: true, passwordToggle: true })} + ${props.showConfirmPassword + ? html`${UI.Input({ label: props.confirmPasswordLabel, type: 'password', value: props.confirmPassword, onInput: (event: Event) => props.setConfirmPassword(getInputValue(event)), required: true, passwordToggle: true })}` + : ''} + ${UI.Button({ type: 'submit', variant: 'primary', loading: props.loading, style: 'width: 100%;', children: props.submitLabel })} +
+ + +
+ `; +} diff --git a/packages/lit-components/src/components/PortalRegister/index.ts b/packages/lit-components/src/components/PortalRegister/index.ts new file mode 100644 index 0000000000..f26b64bdc4 --- /dev/null +++ b/packages/lit-components/src/components/PortalRegister/index.ts @@ -0,0 +1 @@ +export * from './PortalRegister'; diff --git a/packages/lit-components/src/components/PortalRegister/usePortalRegister.test.ts b/packages/lit-components/src/components/PortalRegister/usePortalRegister.test.ts new file mode 100644 index 0000000000..e7b64a26b0 --- /dev/null +++ b/packages/lit-components/src/components/PortalRegister/usePortalRegister.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePortalRegister } from './usePortalRegister'; + +describe('usePortalRegister', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + universalHooksMock.useState.mockClear(); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = usePortalRegister({} as any); + + expect(result.email).toBe(''); + expect(result.password).toBe(''); + expect(result.confirmPassword).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + }); + + it('should validate empty fields', async () => { + const result = usePortalRegister({} as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith('Please fill in all fields'); + }); + + it('should validate password mismatch', async () => { + universalHooksMock.values.push('test@example.com', 'password123', 'different-password'); + const result = usePortalRegister({ showConfirmPassword: true } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith('Passwords do not match'); + }); + + it('should validate minimum password length', async () => { + universalHooksMock.values.push('test@example.com', 'short', 'short'); + const result = usePortalRegister({ passwordMinLength: 10 } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith('Password must be at least 10 characters'); + }); + + it('should dispatch a register event on submit', async () => { + universalHooksMock.values.push('test@example.com', 'password123', 'password123'); + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalRegister({ showConfirmPassword: true, passwordMinLength: 8 } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[4]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[3]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[4]).toHaveBeenNthCalledWith(2, false); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:register'); + expect(event.detail).toEqual({ email: 'test@example.com', password: 'password123' }); + }); + + it('should navigate to login', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalRegister({} as any); + + result.onLogin(); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:navigate'); + expect(event.detail).toEqual({ path: '/login' }); + }); +}); diff --git a/packages/lit-components/src/components/PortalRegister/usePortalRegister.ts b/packages/lit-components/src/components/PortalRegister/usePortalRegister.ts new file mode 100644 index 0000000000..e7f0ca7659 --- /dev/null +++ b/packages/lit-components/src/components/PortalRegister/usePortalRegister.ts @@ -0,0 +1,89 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { PortalRegisterProps } from './PortalRegister'; + +export function usePortalRegister(props: PortalRegisterProps) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function onSubmit(event: Event) { + event.preventDefault(); + if (!email || !password) { + setError('Please fill in all fields'); + return; + } + if (props.showConfirmPassword && password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + if (password.length < (Number(props.passwordMinLength) || 8)) { + setError(`Password must be at least ${props.passwordMinLength || 8} characters`); + return; + } + setLoading(true); + setError(''); + document.dispatchEvent( + new CustomEvent('sq:register', { + bubbles: true, + composed: true, + detail: { email, password }, + }) + ); + setLoading(false); + } + + function onLogin() { + document.dispatchEvent( + new CustomEvent('sq:navigate', { + bubbles: true, + composed: true, + detail: { path: '/login' }, + }) + ); + } + + return { + email, + setEmail, + password, + setPassword, + confirmPassword, + setConfirmPassword, + error, + loading, + onSubmit, + onLogin, + }; +} + +export function useDemoPortalRegister(props: PortalRegisterProps): ReturnType { + const [email, setEmail] = useState('jane@example.com'); + const [password, setPassword] = useState('Password123!'); + const [confirmPassword, setConfirmPassword] = useState('Password123!'); + const [error, setError] = useState(''); + const [loading] = useState(false); + + async function onSubmit(event: Event) { + event.preventDefault(); + setError(''); + } + + function onLogin() { + setError(''); + } + + return { + email, + setEmail, + password, + setPassword, + confirmPassword: props.showConfirmPassword ? confirmPassword : '', + setConfirmPassword, + error, + loading, + onSubmit, + onLogin, + }; +} diff --git a/packages/lit-components/src/components/PortalRegistrationForm/PortalRegistrationForm.stories.ts b/packages/lit-components/src/components/PortalRegistrationForm/PortalRegistrationForm.stories.ts new file mode 100644 index 0000000000..9cb48ed1c0 --- /dev/null +++ b/packages/lit-components/src/components/PortalRegistrationForm/PortalRegistrationForm.stories.ts @@ -0,0 +1,109 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { PortalRegistrationFormView } from './PortalRegistrationFormView'; + +const meta: Meta = { + title: 'Components/PortalRegistrationForm', + component: 'sql-portal-registration-form', + tags: ['autodocs'], + argTypes: { + 'email-label': { control: 'text' }, + 'password-label': { control: 'text' }, + 'confirm-password-label': { control: 'text' }, + 'first-name-label': { control: 'text' }, + 'last-name-label': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'show-name-fields': { control: 'boolean' }, + 'show-confirm-password': { control: 'boolean' }, + 'password-min-length': { control: 'number' }, + 'header-text': { control: 'text' }, + 'terms-text': { control: 'text' }, + 'terms-url': { control: 'text' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + emailLabel: 'Email', + passwordLabel: 'Password', + confirmPasswordLabel: 'Confirm Password', + firstNameLabel: 'First Name', + lastNameLabel: 'Last Name', + submitLabel: 'Register', + showNameFields: true, + showConfirmPassword: true, + passwordMinLength: 8, + headerText: 'Create Your Account', + termsText: undefined, + termsUrl: undefined, +}; + +const baseHookProps = { + firstName: '', + setFirstName: (_value: string) => {}, + lastName: '', + setLastName: (_value: string) => {}, + email: '', + setEmail: (_value: string) => {}, + password: '', + setPassword: (_value: string) => {}, + confirmPassword: '', + setConfirmPassword: (_value: string) => {}, + error: '', + loading: false, + termsAccepted: false, + setTermsAccepted: (_value: boolean) => {}, + onSubmit: async (event: Event) => event.preventDefault(), +}; + +export const Default: Story = {}; + +export const WithoutNameFields: Story = { + args: { + 'show-name-fields': false, + }, +}; + +export const WithTerms: Story = { + args: { + 'terms-text': 'I accept the terms and conditions', + 'terms-url': 'https://example.com/terms', + }, +}; + +export const WithError: Story = { + render: () => + PortalRegistrationFormView({ + ...baseProps, + ...baseHookProps, + error: 'Passwords do not match', + }), +}; + +export const Loading: Story = { + render: () => + PortalRegistrationFormView({ + ...baseProps, + ...baseHookProps, + loading: true, + }), +}; diff --git a/packages/lit-components/src/components/PortalRegistrationForm/PortalRegistrationForm.ts b/packages/lit-components/src/components/PortalRegistrationForm/PortalRegistrationForm.ts new file mode 100644 index 0000000000..0174a5c2b6 --- /dev/null +++ b/packages/lit-components/src/components/PortalRegistrationForm/PortalRegistrationForm.ts @@ -0,0 +1,75 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalRegistrationFormView } from './PortalRegistrationFormView'; +import { useDemoPortalRegistrationForm, usePortalRegistrationForm } from './usePortalRegistrationForm'; + +export interface PortalRegistrationFormProps { + emailLabel: string; + passwordLabel: string; + confirmPasswordLabel: string; + firstNameLabel: string; + lastNameLabel: string; + submitLabel: string; + showNameFields: boolean; + showConfirmPassword: boolean; + passwordMinLength: number; + headerText: string; + termsText?: string; + termsUrl?: string; +} + +function getBooleanAttribute(host: HTMLElement, name: string, defaultValue = false) { + if (!host.hasAttribute(name)) { + return defaultValue; + } + + const value = host.getAttribute(name); + return value === '' || value === 'true'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-registration-form': HTMLElement; + } +} + +export const PortalRegistrationForm = useComponent( + (host) => { + const rawProps = getProps(host); + const passwordMinLength = Number(rawProps.passwordMinLength); + const props: PortalRegistrationFormProps = { + emailLabel: rawProps.emailLabel || 'Email', + passwordLabel: rawProps.passwordLabel || 'Password', + confirmPasswordLabel: rawProps.confirmPasswordLabel || 'Confirm Password', + firstNameLabel: rawProps.firstNameLabel || 'First Name', + lastNameLabel: rawProps.lastNameLabel || 'Last Name', + submitLabel: rawProps.submitLabel || 'Register', + showNameFields: getBooleanAttribute(host, 'show-name-fields', true), + showConfirmPassword: getBooleanAttribute(host, 'show-confirm-password', true), + passwordMinLength: Number.isFinite(passwordMinLength) ? passwordMinLength : 8, + headerText: rawProps.headerText || 'Create Your Account', + termsText: rawProps.termsText || undefined, + termsUrl: rawProps.termsUrl || undefined, + }; + + const hookProps = isDemo() ? useDemoPortalRegistrationForm(props) : usePortalRegistrationForm(props); + + return PortalRegistrationFormView({ ...props, ...hookProps }); + }, + 'sql-portal-registration-form', + [ + 'email-label', + 'password-label', + 'confirm-password-label', + 'first-name-label', + 'last-name-label', + 'submit-label', + 'show-name-fields', + 'show-confirm-password', + 'password-min-length', + 'header-text', + 'terms-text', + 'terms-url', + ] as const +); diff --git a/packages/lit-components/src/components/PortalRegistrationForm/PortalRegistrationFormView.ts b/packages/lit-components/src/components/PortalRegistrationForm/PortalRegistrationFormView.ts new file mode 100644 index 0000000000..7c942bc92c --- /dev/null +++ b/packages/lit-components/src/components/PortalRegistrationForm/PortalRegistrationFormView.ts @@ -0,0 +1,95 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { PortalRegistrationFormProps } from './PortalRegistrationForm'; +import { usePortalRegistrationForm } from './usePortalRegistrationForm'; + +type FormEventTarget = EventTarget & { value?: string; checked?: boolean }; + +const styles = ` + :host { + display: block; + } + + .registration-form { + display: flex; + flex-direction: column; + gap: var(--sl-spacing-medium); + max-width: 28rem; + margin: 0 auto; + } + + .name-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sl-spacing-small); + } + + h2, + p { + margin: 0; + } + + .error-text { + color: var(--sl-color-danger-600); + } + + .terms-copy { + display: inline-flex; + gap: 0.25rem; + flex-wrap: wrap; + } +`; + +const getValue = (event: Event) => String((event.target as FormEventTarget).value ?? ''); +const getChecked = (event: Event) => Boolean((event.target as FormEventTarget).checked); + +export function PortalRegistrationFormView( + props: PortalRegistrationFormProps & ReturnType +) { + return html` + +
+

${props.headerText}

+ ${props.showNameFields + ? html` +
+ ${UI.Input({ name: 'firstName', label: props.firstNameLabel, value: props.firstName, disabled: props.loading, onInput: (event: Event) => props.setFirstName(getValue(event)) })} + ${UI.Input({ name: 'lastName', label: props.lastNameLabel, value: props.lastName, disabled: props.loading, onInput: (event: Event) => props.setLastName(getValue(event)) })} +
+ ` + : ''} + ${UI.Input({ name: 'email', type: 'email', label: props.emailLabel, value: props.email, disabled: props.loading, onInput: (event: Event) => props.setEmail(getValue(event)) })} + ${UI.Input({ name: 'password', type: 'password', label: props.passwordLabel, helpText: `Minimum ${props.passwordMinLength} characters`, value: props.password, disabled: props.loading, onInput: (event: Event) => props.setPassword(getValue(event)) })} + ${props.showConfirmPassword + ? html` + ${UI.Input({ name: 'confirmPassword', type: 'password', label: props.confirmPasswordLabel, value: props.confirmPassword, disabled: props.loading, onInput: (event: Event) => props.setConfirmPassword(getValue(event)) })} + ` + : ''} + ${props.termsText + ? html` + ${UI.Checkbox({ + checked: props.termsAccepted, + disabled: props.loading, + onChange: (event: Event) => props.setTermsAccepted(getChecked(event)), + children: html` + ${props.termsText} + ${props.termsUrl + ? html`View terms` + : ''} + `, + })} + ` + : ''} + ${props.error ? html`

${props.error}

` : ''} + ${UI.Button({ + type: 'submit', + variant: 'primary', + disabled: props.loading, + prefix: props.loading ? UI.Spinner({ style: 'font-size: 1rem;' }) : undefined, + children: props.submitLabel, + })} +
+ `; +} diff --git a/packages/lit-components/src/components/PortalRegistrationForm/index.ts b/packages/lit-components/src/components/PortalRegistrationForm/index.ts new file mode 100644 index 0000000000..a3db3ba97e --- /dev/null +++ b/packages/lit-components/src/components/PortalRegistrationForm/index.ts @@ -0,0 +1 @@ +export * from './PortalRegistrationForm'; diff --git a/packages/lit-components/src/components/PortalRegistrationForm/usePortalRegistrationForm.test.ts b/packages/lit-components/src/components/PortalRegistrationForm/usePortalRegistrationForm.test.ts new file mode 100644 index 0000000000..51bab9265e --- /dev/null +++ b/packages/lit-components/src/components/PortalRegistrationForm/usePortalRegistrationForm.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePortalRegistrationForm } from './usePortalRegistrationForm'; + +describe('usePortalRegistrationForm', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + universalHooksMock.useState.mockClear(); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = usePortalRegistrationForm({} as any); + + expect(result.firstName).toBe(''); + expect(result.lastName).toBe(''); + expect(result.email).toBe(''); + expect(result.password).toBe(''); + expect(result.confirmPassword).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + expect(result.termsAccepted).toBe(false); + }); + + it('should require email and password', async () => { + const result = usePortalRegistrationForm({} as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[5]).toHaveBeenCalledWith('Email and password are required'); + }); + + it('should require name fields when enabled', async () => { + universalHooksMock.values.push('', '', 'test@example.com', 'password123', 'password123'); + const result = usePortalRegistrationForm({ showNameFields: true } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[5]).toHaveBeenCalledWith('Name fields are required'); + }); + + it('should validate password mismatch', async () => { + universalHooksMock.values.push('', '', 'test@example.com', 'password123', 'different-password'); + const result = usePortalRegistrationForm({ showConfirmPassword: true } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[5]).toHaveBeenCalledWith('Passwords do not match'); + }); + + it('should validate minimum password length', async () => { + universalHooksMock.values.push('', '', 'test@example.com', 'short', 'short'); + const result = usePortalRegistrationForm({ passwordMinLength: 10 } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[5]).toHaveBeenCalledWith('Password must be at least 10 characters'); + }); + + it('should require terms acceptance when terms are shown', async () => { + universalHooksMock.values.push('', '', 'test@example.com', 'password123', 'password123', '', false, false); + const result = usePortalRegistrationForm({ termsText: 'Terms and conditions' } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[5]).toHaveBeenCalledWith('You must accept the terms'); + }); + + it('should dispatch a registration event on successful submit', async () => { + universalHooksMock.values.push('Jane', 'Doe', 'jane@example.com', 'password123', 'password123', '', false, true); + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalRegistrationForm({ + showNameFields: true, + showConfirmPassword: true, + passwordMinLength: 8, + termsText: 'Terms and conditions', + } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[6]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[5]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[6]).toHaveBeenNthCalledWith(2, false); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:registration-submit'); + expect(event.detail).toEqual({ + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@example.com', + password: 'password123', + }); + }); +}); diff --git a/packages/lit-components/src/components/PortalRegistrationForm/usePortalRegistrationForm.ts b/packages/lit-components/src/components/PortalRegistrationForm/usePortalRegistrationForm.ts new file mode 100644 index 0000000000..29b9239392 --- /dev/null +++ b/packages/lit-components/src/components/PortalRegistrationForm/usePortalRegistrationForm.ts @@ -0,0 +1,101 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { PortalRegistrationFormProps } from './PortalRegistrationForm'; + +export function usePortalRegistrationForm(props: PortalRegistrationFormProps) { + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [termsAccepted, setTermsAccepted] = useState(false); + + async function onSubmit(e: Event) { + e.preventDefault(); + if (!email || !password) { + setError('Email and password are required'); + return; + } + if (props.showNameFields && (!firstName || !lastName)) { + setError('Name fields are required'); + return; + } + if (props.showConfirmPassword && password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + if (password.length < (Number(props.passwordMinLength) || 8)) { + setError(`Password must be at least ${props.passwordMinLength || 8} characters`); + return; + } + if (props.termsText && !termsAccepted) { + setError('You must accept the terms'); + return; + } + + setLoading(true); + setError(''); + const event = new CustomEvent('sq:registration-submit', { + bubbles: true, + composed: true, + detail: { firstName, lastName, email, password }, + }); + document.dispatchEvent(event); + setLoading(false); + } + + return { + firstName, + setFirstName, + lastName, + setLastName, + email, + setEmail, + password, + setPassword, + confirmPassword, + setConfirmPassword, + error, + loading, + termsAccepted, + setTermsAccepted, + onSubmit, + }; +} + +export function useDemoPortalRegistrationForm( + props: PortalRegistrationFormProps +): ReturnType { + const [firstName, setFirstName] = useState('Jane'); + const [lastName, setLastName] = useState('Doe'); + const [email, setEmail] = useState('jane@example.com'); + const [password, setPassword] = useState('Password123!'); + const [confirmPassword, setConfirmPassword] = useState('Password123!'); + const [error, setError] = useState(''); + const [loading] = useState(false); + const [termsAccepted, setTermsAccepted] = useState(Boolean(props.termsText)); + + async function onSubmit(event: Event) { + event.preventDefault(); + setError(''); + } + + return { + firstName: props.showNameFields ? firstName : '', + setFirstName, + lastName: props.showNameFields ? lastName : '', + setLastName, + email, + setEmail, + password, + setPassword, + confirmPassword: props.showConfirmPassword ? confirmPassword : '', + setConfirmPassword, + error, + loading, + termsAccepted, + setTermsAccepted, + onSubmit, + }; +} diff --git a/packages/lit-components/src/components/PortalResetPassword/PortalResetPassword.stories.ts b/packages/lit-components/src/components/PortalResetPassword/PortalResetPassword.stories.ts new file mode 100644 index 0000000000..460a56f048 --- /dev/null +++ b/packages/lit-components/src/components/PortalResetPassword/PortalResetPassword.stories.ts @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { PortalResetPasswordView } from './PortalResetPasswordView'; + +const meta: Meta = { + title: 'Components/PortalResetPassword', + component: 'sql-portal-reset-password', + tags: ['autodocs'], + argTypes: { + 'password-label': { control: 'text' }, + 'confirm-password-label': { control: 'text' }, + 'submit-label': { control: 'text' }, + 'header-text': { control: 'text' }, + 'success-message': { control: 'text' }, + 'password-min-length': { control: 'number' }, + }, + render: (args) => html` + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + passwordLabel: 'New Password', + confirmPasswordLabel: 'Confirm Password', + submitLabel: 'Reset Password', + headerText: 'Reset Password', + successMessage: 'Password reset successfully. You can now sign in.', + passwordMinLength: 8, + password: 'password123', + setPassword: () => undefined, + confirmPassword: 'password123', + setConfirmPassword: () => undefined, + error: '', + loading: false, + success: false, + resetCode: 'demo-reset-code', + onSubmit: (event: Event) => event.preventDefault(), +}; + +export const Default: Story = { + render: () => PortalResetPasswordView(baseProps), +}; + +export const WithError: Story = { + render: () => + PortalResetPasswordView({ + ...baseProps, + error: 'Passwords do not match', + confirmPassword: 'different-password', + }), +}; + +export const Success: Story = { + render: () => + PortalResetPasswordView({ + ...baseProps, + success: true, + }), +}; + +export const InvalidCode: Story = { + render: () => + PortalResetPasswordView({ + ...baseProps, + resetCode: '', + }), +}; + +export const CustomLabels: Story = { + render: () => + PortalResetPasswordView({ + ...baseProps, + passwordLabel: 'Create New Password', + confirmPasswordLabel: 'Repeat New Password', + submitLabel: 'Save Password', + headerText: 'Create a fresh password', + successMessage: 'Your password has been updated.', + }), +}; diff --git a/packages/lit-components/src/components/PortalResetPassword/PortalResetPassword.ts b/packages/lit-components/src/components/PortalResetPassword/PortalResetPassword.ts new file mode 100644 index 0000000000..f1bb182413 --- /dev/null +++ b/packages/lit-components/src/components/PortalResetPassword/PortalResetPassword.ts @@ -0,0 +1,57 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalResetPasswordView } from './PortalResetPasswordView'; +import { useDemoPortalResetPassword, usePortalResetPassword } from './usePortalResetPassword'; + +export interface PortalResetPasswordProps { + passwordLabel: string; + confirmPasswordLabel: string; + submitLabel: string; + headerText: string; + successMessage: string; + passwordMinLength: number; +} + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim() !== '') return Number(value); + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-reset-password': HTMLElement; + } +} + +export const PortalResetPassword = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: PortalResetPasswordProps = { + passwordLabel: typeof rawProps.passwordLabel === 'string' ? rawProps.passwordLabel : 'New Password', + confirmPasswordLabel: + typeof rawProps.confirmPasswordLabel === 'string' ? rawProps.confirmPasswordLabel : 'Confirm Password', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Reset Password', + headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Reset Password', + successMessage: + typeof rawProps.successMessage === 'string' + ? rawProps.successMessage + : 'Password reset successfully. You can now sign in.', + passwordMinLength: parseNumber(rawProps.passwordMinLength, 8), + }; + + const hookProps = isDemo() ? useDemoPortalResetPassword(props) : usePortalResetPassword(props); + + return PortalResetPasswordView({ ...props, ...hookProps }); + }, + 'sql-portal-reset-password', + [ + 'password-label', + 'confirm-password-label', + 'submit-label', + 'header-text', + 'success-message', + 'password-min-length', + ] as const +); diff --git a/packages/lit-components/src/components/PortalResetPassword/PortalResetPasswordView.ts b/packages/lit-components/src/components/PortalResetPassword/PortalResetPasswordView.ts new file mode 100644 index 0000000000..9690509f93 --- /dev/null +++ b/packages/lit-components/src/components/PortalResetPassword/PortalResetPasswordView.ts @@ -0,0 +1,51 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { PortalResetPasswordProps } from './PortalResetPassword'; +import { usePortalResetPassword } from './usePortalResetPassword'; + +const getInputValue = (event: Event) => ((event.target as HTMLInputElement & { value: string })?.value ?? ''); + +export function PortalResetPasswordView(props: PortalResetPasswordProps & ReturnType) { + return html` + +
+

${props.headerText}

+ ${!props.resetCode && !props.success ? html`${UI.Alert({ variant: 'warning', open: true, children: 'Invalid or expired reset link.' })}` : ''} + ${props.error ? html`${UI.Alert({ variant: 'danger', open: true, children: props.error })}` : ''} + ${props.success + ? html`${UI.Alert({ variant: 'success', open: true, children: props.successMessage })}` + : html` +
+ ${UI.Input({ label: props.passwordLabel, type: 'password', value: props.password, onInput: (event: Event) => props.setPassword(getInputValue(event)), required: true, passwordToggle: true })} + ${UI.Input({ label: props.confirmPasswordLabel, type: 'password', value: props.confirmPassword, onInput: (event: Event) => props.setConfirmPassword(getInputValue(event)), required: true, passwordToggle: true })} + ${UI.Button({ type: 'submit', variant: 'primary', loading: props.loading, disabled: !props.resetCode, style: 'width: 100%;', children: props.submitLabel })} +
+ `} +
+ `; +} diff --git a/packages/lit-components/src/components/PortalResetPassword/index.ts b/packages/lit-components/src/components/PortalResetPassword/index.ts new file mode 100644 index 0000000000..543d90f280 --- /dev/null +++ b/packages/lit-components/src/components/PortalResetPassword/index.ts @@ -0,0 +1 @@ +export * from './PortalResetPassword'; diff --git a/packages/lit-components/src/components/PortalResetPassword/usePortalResetPassword.test.ts b/packages/lit-components/src/components/PortalResetPassword/usePortalResetPassword.test.ts new file mode 100644 index 0000000000..3893d07105 --- /dev/null +++ b/packages/lit-components/src/components/PortalResetPassword/usePortalResetPassword.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePortalResetPassword } from './usePortalResetPassword'; + +describe('usePortalResetPassword', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + universalHooksMock.useState.mockClear(); + + const windowMock = { + location: { search: '' }, + history: { + replaceState: vi.fn((_state: unknown, _title: string, url: string) => { + windowMock.location.search = url.includes('?') ? url.slice(url.indexOf('?')) : ''; + }), + }, + }; + + vi.stubGlobal('window', windowMock); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = usePortalResetPassword({} as any); + + expect(result.password).toBe(''); + expect(result.confirmPassword).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + expect(result.success).toBe(false); + expect(result.resetCode).toBe(''); + }); + + it('should extract resetCode from the url', () => { + window.history.replaceState({}, '', '/?oobCode=reset-token'); + + const result = usePortalResetPassword({} as any); + + expect(result.resetCode).toBe('reset-token'); + }); + + it('should validate a missing resetCode', async () => { + const result = usePortalResetPassword({} as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('Invalid or expired reset link'); + }); + + it('should validate a missing password', async () => { + window.history.replaceState({}, '', '/?oobCode=reset-token'); + const result = usePortalResetPassword({} as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('Password is required'); + }); + + it('should validate password mismatch', async () => { + window.history.replaceState({}, '', '/?oobCode=reset-token'); + universalHooksMock.values.push('password123', 'different-password'); + const result = usePortalResetPassword({} as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('Passwords do not match'); + }); + + it('should validate minimum password length', async () => { + window.history.replaceState({}, '', '/?oobCode=reset-token'); + universalHooksMock.values.push('short', 'short'); + const result = usePortalResetPassword({ passwordMinLength: 10 } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith('Password must be at least 10 characters'); + }); + + it('should dispatch a reset-password event on successful submit', async () => { + window.history.replaceState({}, '', '/?oobCode=reset-token'); + universalHooksMock.values.push('password123', 'password123'); + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = usePortalResetPassword({ passwordMinLength: 8 } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[3]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[2]).toHaveBeenCalledWith(''); + expect(universalHooksMock.setters[4]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[3]).toHaveBeenNthCalledWith(2, false); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:reset-password'); + expect(event.detail).toEqual({ password: 'password123', resetCode: 'reset-token' }); + }); +}); diff --git a/packages/lit-components/src/components/PortalResetPassword/usePortalResetPassword.ts b/packages/lit-components/src/components/PortalResetPassword/usePortalResetPassword.ts new file mode 100644 index 0000000000..e5b1649b64 --- /dev/null +++ b/packages/lit-components/src/components/PortalResetPassword/usePortalResetPassword.ts @@ -0,0 +1,98 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { PortalResetPasswordProps } from './PortalResetPassword'; + +export function usePortalResetPassword(props: PortalResetPasswordProps) { + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + const search = typeof window === 'undefined' ? '' : window.location.search; + const urlParams = new URLSearchParams(search); + const resetCode = urlParams.get('oobCode') || urlParams.get('code') || ''; + + async function onSubmit(event: Event) { + event.preventDefault(); + if (!resetCode) { + setError('Invalid or expired reset link'); + return; + } + if (!password) { + setError('Password is required'); + return; + } + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + if (password.length < (Number(props.passwordMinLength) || 8)) { + setError(`Password must be at least ${props.passwordMinLength || 8} characters`); + return; + } + setLoading(true); + setError(''); + document.dispatchEvent( + new CustomEvent('sq:reset-password', { + bubbles: true, + composed: true, + detail: { password, resetCode }, + }) + ); + setSuccess(true); + setLoading(false); + } + + return { + password, + setPassword, + confirmPassword, + setConfirmPassword, + error, + loading, + success, + resetCode, + onSubmit, + }; +} + +export function useDemoPortalResetPassword(props: PortalResetPasswordProps): ReturnType { + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const resetCode = 'demo-reset-code'; + + async function onSubmit(event: Event) { + event.preventDefault(); + if (!password) { + setError('Password is required'); + return; + } + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + if (password.length < (Number(props.passwordMinLength) || 8)) { + setError(`Password must be at least ${props.passwordMinLength || 8} characters`); + return; + } + setLoading(true); + setError(''); + setSuccess(true); + setLoading(false); + } + + return { + password, + setPassword, + confirmPassword, + setConfirmPassword, + error, + loading, + success, + resetCode, + onSubmit, + }; +} diff --git a/packages/lit-components/src/components/PortalVerifyEmail/PortalVerifyEmail.stories.ts b/packages/lit-components/src/components/PortalVerifyEmail/PortalVerifyEmail.stories.ts new file mode 100644 index 0000000000..1cf04a3502 --- /dev/null +++ b/packages/lit-components/src/components/PortalVerifyEmail/PortalVerifyEmail.stories.ts @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { PortalVerifyEmailView } from './PortalVerifyEmailView'; + +const meta: Meta = { + title: 'Components/PortalVerifyEmail', + component: 'sql-portal-verify-email', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + verifyingText: 'Verifying your email...', + successText: 'Email verified! Redirecting...', + errorText: 'Verification failed. Please try again.', + redirectUrl: '/', + token: 'demo-token', +}; + +export const Verifying: Story = { + render: () => PortalVerifyEmailView({ ...baseProps, status: 'verifying' }), +}; + +export const Success: Story = { + render: () => PortalVerifyEmailView({ ...baseProps, status: 'success' }), +}; + +export const Error: Story = { + render: () => PortalVerifyEmailView({ ...baseProps, status: 'error' }), +}; diff --git a/packages/lit-components/src/components/PortalVerifyEmail/PortalVerifyEmail.ts b/packages/lit-components/src/components/PortalVerifyEmail/PortalVerifyEmail.ts new file mode 100644 index 0000000000..8ada7d51d2 --- /dev/null +++ b/packages/lit-components/src/components/PortalVerifyEmail/PortalVerifyEmail.ts @@ -0,0 +1,36 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { PortalVerifyEmailView } from './PortalVerifyEmailView'; +import { useDemoPortalVerifyEmail, usePortalVerifyEmail } from './usePortalVerifyEmail'; + +export interface PortalVerifyEmailProps { + verifyingText: string; + successText: string; + errorText: string; + redirectUrl: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-portal-verify-email': HTMLElement; + } +} + +export const PortalVerifyEmail = useComponent( + (host) => { + const rawProps = getProps(host); + const props: PortalVerifyEmailProps = { + verifyingText: rawProps.verifyingText || 'Verifying your email...', + successText: rawProps.successText || 'Email verified! Redirecting...', + errorText: rawProps.errorText || 'Verification failed. Please try again.', + redirectUrl: rawProps.redirectUrl || '/', + }; + + const hookProps = isDemo() ? useDemoPortalVerifyEmail(props) : usePortalVerifyEmail(props); + + return PortalVerifyEmailView({ ...props, ...hookProps }); + }, + 'sql-portal-verify-email', + ['verifying-text', 'success-text', 'error-text', 'redirect-url'] as const +); diff --git a/packages/lit-components/src/components/PortalVerifyEmail/PortalVerifyEmailView.ts b/packages/lit-components/src/components/PortalVerifyEmail/PortalVerifyEmailView.ts new file mode 100644 index 0000000000..58b79a8f58 --- /dev/null +++ b/packages/lit-components/src/components/PortalVerifyEmail/PortalVerifyEmailView.ts @@ -0,0 +1,31 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { PortalVerifyEmailProps } from './PortalVerifyEmail'; +import { usePortalVerifyEmail } from './usePortalVerifyEmail'; + +export function PortalVerifyEmailView( + props: PortalVerifyEmailProps & ReturnType +) { + return html` + +
+ ${props.status === 'verifying' + ? html`${UI.Spinner({ style: 'font-size: 2rem;' })} +

${props.verifyingText}

` + : props.status === 'success' + ? html`${UI.Icon({ name: 'check-circle', style: 'font-size: 3rem; color: var(--sl-color-success-600);' })} +

${props.successText}

` + : html`${UI.Icon({ name: 'exclamation-circle', style: 'font-size: 3rem; color: var(--sl-color-danger-600);' })} +

${props.errorText}

`} +
+ `; +} diff --git a/packages/lit-components/src/components/PortalVerifyEmail/index.ts b/packages/lit-components/src/components/PortalVerifyEmail/index.ts new file mode 100644 index 0000000000..de9e0577cb --- /dev/null +++ b/packages/lit-components/src/components/PortalVerifyEmail/index.ts @@ -0,0 +1 @@ +export * from './PortalVerifyEmail'; diff --git a/packages/lit-components/src/components/PortalVerifyEmail/usePortalVerifyEmail.test.ts b/packages/lit-components/src/components/PortalVerifyEmail/usePortalVerifyEmail.test.ts new file mode 100644 index 0000000000..4972fd9f4f --- /dev/null +++ b/packages/lit-components/src/components/PortalVerifyEmail/usePortalVerifyEmail.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { usePortalVerifyEmail } from './usePortalVerifyEmail'; + +describe('usePortalVerifyEmail', () => { + beforeEach(() => { + universalHooksMock.useState.mockClear(); + vi.restoreAllMocks(); + vi.stubGlobal('window', { + location: { search: '' }, + }); + }); + + it('should return verifying status by default', () => { + const result = usePortalVerifyEmail({} as never); + + expect(result.status).toBe('verifying'); + expect(universalHooksMock.useState).toHaveBeenCalledWith('verifying'); + }); + + it('should extract the token from oobCode', () => { + window.location.search = '?oobCode=test-oob-code'; + + const result = usePortalVerifyEmail({} as never); + + expect(result.token).toBe('test-oob-code'); + }); + + it('should extract the token from token when oobCode is missing', () => { + window.location.search = '?token=test-token'; + + const result = usePortalVerifyEmail({} as never); + + expect(result.token).toBe('test-token'); + }); + + it('should return an empty token when no token params are present', () => { + const result = usePortalVerifyEmail({} as never); + + expect(result.token).toBe(''); + }); +}); diff --git a/packages/lit-components/src/components/PortalVerifyEmail/usePortalVerifyEmail.ts b/packages/lit-components/src/components/PortalVerifyEmail/usePortalVerifyEmail.ts new file mode 100644 index 0000000000..628bcb73ff --- /dev/null +++ b/packages/lit-components/src/components/PortalVerifyEmail/usePortalVerifyEmail.ts @@ -0,0 +1,22 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { PortalVerifyEmailProps } from './PortalVerifyEmail'; + +export function usePortalVerifyEmail(_props: PortalVerifyEmailProps) { + const [status] = useState<'verifying' | 'success' | 'error'>('verifying'); + + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get('oobCode') || urlParams.get('token') || ''; + + if (!token) { + // BUG TRANSFERRED: In mint-components, if no token is found, the component stays in + // 'verifying' state indefinitely rather than showing an error. We replicate it here. + } + + return { status, token }; +} + +export function useDemoPortalVerifyEmail(_props: PortalVerifyEmailProps): ReturnType { + const [status] = useState<'verifying' | 'success' | 'error'>('success'); + + return { status, token: 'demo-token' }; +} diff --git a/packages/lit-components/src/components/ProgramExplainer/ProgramExplainer.stories.ts b/packages/lit-components/src/components/ProgramExplainer/ProgramExplainer.stories.ts new file mode 100644 index 0000000000..e5a5b139aa --- /dev/null +++ b/packages/lit-components/src/components/ProgramExplainer/ProgramExplainer.stories.ts @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import type { ProgramExplainerProps } from './ProgramExplainer'; + +const meta = { + title: 'Components/ProgramExplainer', + component: 'sql-program-explainer', + tags: ['autodocs'], + argTypes: { + header: { control: 'text' }, + description: { control: 'text' }, + layout: { control: 'select', options: ['horizontal', 'vertical'] }, + }, + render: (args: Partial) => html` + + + + `, +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + header: 'How it works', + }, +}; + +export const Vertical: Story = { + args: { + header: 'How it works', + layout: 'vertical', + }, +}; + +export const WithHeaderAndDescription: Story = { + args: { + header: 'Refer friends in three easy steps', + description: 'Invite friends, track their progress, and unlock rewards as they convert.', + }, +}; diff --git a/packages/lit-components/src/components/ProgramExplainer/ProgramExplainer.ts b/packages/lit-components/src/components/ProgramExplainer/ProgramExplainer.ts new file mode 100644 index 0000000000..ddebf568a4 --- /dev/null +++ b/packages/lit-components/src/components/ProgramExplainer/ProgramExplainer.ts @@ -0,0 +1,34 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ProgramExplainerView } from './ProgramExplainerView'; + +export interface ProgramExplainerProps { + header?: string; + description?: string; + layout: 'horizontal' | 'vertical'; +} + +function parseLayout(value: unknown): ProgramExplainerProps['layout'] { + return value === 'vertical' ? 'vertical' : 'horizontal'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-program-explainer': HTMLElement; + } +} + +export const ProgramExplainer = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: ProgramExplainerProps = { + header: typeof rawProps.header === 'string' ? rawProps.header : undefined, + description: typeof rawProps.description === 'string' ? rawProps.description : undefined, + layout: parseLayout(rawProps.layout), + }; + + return ProgramExplainerView(props); + }, + 'sql-program-explainer', + ['header', 'description', 'layout'] as const +); diff --git a/packages/lit-components/src/components/ProgramExplainer/ProgramExplainerView.ts b/packages/lit-components/src/components/ProgramExplainer/ProgramExplainerView.ts new file mode 100644 index 0000000000..aa7431535e --- /dev/null +++ b/packages/lit-components/src/components/ProgramExplainer/ProgramExplainerView.ts @@ -0,0 +1,47 @@ +import { html } from 'lit'; +import { ProgramExplainerProps } from './ProgramExplainer'; + +export function ProgramExplainerView(props: ProgramExplainerProps) { + return html` + +
+ ${props.header ? html`

${props.header}

` : null} + ${props.description + ? html`

${props.description}

` + : null} +
+ +
+
+ `; +} diff --git a/packages/lit-components/src/components/ProgramExplainer/index.ts b/packages/lit-components/src/components/ProgramExplainer/index.ts new file mode 100644 index 0000000000..e9198d76ef --- /dev/null +++ b/packages/lit-components/src/components/ProgramExplainer/index.ts @@ -0,0 +1 @@ +export * from './ProgramExplainer'; diff --git a/packages/lit-components/src/components/ProgramExplainerStep/ProgramExplainerStep.stories.ts b/packages/lit-components/src/components/ProgramExplainerStep/ProgramExplainerStep.stories.ts new file mode 100644 index 0000000000..985a800a9d --- /dev/null +++ b/packages/lit-components/src/components/ProgramExplainerStep/ProgramExplainerStep.stories.ts @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import type { ProgramExplainerStepProps } from './ProgramExplainerStep'; + +const defaultArgs: ProgramExplainerStepProps = { + stepTitle: 'Share your link', + stepDescription: 'Send your referral link to a friend.', + iconColor: 'var(--sl-color-primary-600)', +}; + +const meta = { + title: 'Components/ProgramExplainerStep', + component: 'sql-program-explainer-step', + tags: ['autodocs'], + argTypes: { + stepTitle: { control: 'text' }, + stepDescription: { control: 'text' }, + stepNumber: { control: 'number' }, + icon: { control: 'text' }, + iconColor: { control: 'color' }, + }, + render: (args: Partial) => html``, +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithIcon: Story = { + args: { + icon: 'gift-fill', + }, +}; + +export const WithNumber: Story = { + args: { + stepNumber: 2, + icon: undefined, + }, +}; + +export const WithDescription: Story = { + args: { + stepDescription: 'Invite friends, wait for them to convert, and collect your reward.', + icon: 'people-fill', + }, +}; diff --git a/packages/lit-components/src/components/ProgramExplainerStep/ProgramExplainerStep.ts b/packages/lit-components/src/components/ProgramExplainerStep/ProgramExplainerStep.ts new file mode 100644 index 0000000000..a0db808d63 --- /dev/null +++ b/packages/lit-components/src/components/ProgramExplainerStep/ProgramExplainerStep.ts @@ -0,0 +1,47 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ProgramExplainerStepView } from './ProgramExplainerStepView'; + +export interface ProgramExplainerStepProps { + stepTitle?: string; + stepDescription?: string; + stepNumber?: number; + icon?: string; + iconColor: string; +} + +const parseNumber = (value: unknown) => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-program-explainer-step': HTMLElement; + } +} + +export const ProgramExplainerStep = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: ProgramExplainerStepProps = { + stepTitle: typeof rawProps.stepTitle === 'string' ? rawProps.stepTitle : undefined, + stepDescription: + typeof rawProps.stepDescription === 'string' ? rawProps.stepDescription : undefined, + stepNumber: parseNumber(rawProps.stepNumber), + icon: typeof rawProps.icon === 'string' ? rawProps.icon : undefined, + iconColor: + typeof rawProps.iconColor === 'string' + ? rawProps.iconColor + : 'var(--sl-color-primary-600)', + }; + + return ProgramExplainerStepView(props); + }, + 'sql-program-explainer-step', + ['step-title', 'step-description', 'step-number', 'icon', 'icon-color'] as const +); diff --git a/packages/lit-components/src/components/ProgramExplainerStep/ProgramExplainerStepView.ts b/packages/lit-components/src/components/ProgramExplainerStep/ProgramExplainerStepView.ts new file mode 100644 index 0000000000..e669aff147 --- /dev/null +++ b/packages/lit-components/src/components/ProgramExplainerStep/ProgramExplainerStepView.ts @@ -0,0 +1,71 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { ProgramExplainerStepProps } from './ProgramExplainerStep'; + +export function ProgramExplainerStepView(props: ProgramExplainerStepProps) { + return html` + +
+ ${props.icon + ? html`
${UI.Icon({ name: props.icon })}
` + : props.stepNumber !== undefined + ? html`
${props.stepNumber}
` + : null} +
${props.stepTitle}
+ ${props.stepDescription + ? html`
${props.stepDescription}
` + : null} + +
+ `; +} diff --git a/packages/lit-components/src/components/ProgramExplainerStep/index.ts b/packages/lit-components/src/components/ProgramExplainerStep/index.ts new file mode 100644 index 0000000000..fc72f238cb --- /dev/null +++ b/packages/lit-components/src/components/ProgramExplainerStep/index.ts @@ -0,0 +1 @@ +export * from './ProgramExplainerStep'; diff --git a/packages/lit-components/src/components/ProgramMenu/ProgramMenu.stories.ts b/packages/lit-components/src/components/ProgramMenu/ProgramMenu.stories.ts new file mode 100644 index 0000000000..f48405c4f0 --- /dev/null +++ b/packages/lit-components/src/components/ProgramMenu/ProgramMenu.stories.ts @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { ProgramMenuView } from './ProgramMenuView'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = '******'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: '******', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/ProgramMenu', + component: 'sql-program-menu', + tags: ['autodocs'], + argTypes: { + 'program-id': { control: 'text' }, + }, + render: (args) => { + setupGlobals(); + return html``; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + render: () => + ProgramMenuView({ + programId: '41863', + programs: [], + selected: '41863', + loading: true, + onSelect: () => undefined, + }), +}; + +export const WithPrograms: Story = { + render: () => + ProgramMenuView({ + programId: '41863', + selected: '41863', + loading: false, + onSelect: () => undefined, + programs: [ + { programId: '41863', program: { name: 'Advocate Program' } }, + { programId: '51234', program: { name: 'VIP Rewards' } }, + { programId: '92345', program: { name: 'Partner Referrals' } }, + ], + }), +}; diff --git a/packages/lit-components/src/components/ProgramMenu/ProgramMenu.ts b/packages/lit-components/src/components/ProgramMenu/ProgramMenu.ts new file mode 100644 index 0000000000..f07199cf3b --- /dev/null +++ b/packages/lit-components/src/components/ProgramMenu/ProgramMenu.ts @@ -0,0 +1,37 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ProgramMenuView } from './ProgramMenuView'; +import { useDemoProgramMenu, useProgramMenu } from './useProgramMenu'; + +export interface ProgramMenuItem { + programId: string; + program?: { + name?: string; + }; + shareLink?: string; +} + +export interface ProgramMenuProps { + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-program-menu': HTMLElement; + } +} + +export const ProgramMenu = useComponent( + (host) => { + const props: ProgramMenuProps = { + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoProgramMenu(props) : useProgramMenu(props); + + return ProgramMenuView({ ...props, ...hookProps }); + }, + 'sql-program-menu', + ['program-id'] as const +); diff --git a/packages/lit-components/src/components/ProgramMenu/ProgramMenuView.ts b/packages/lit-components/src/components/ProgramMenu/ProgramMenuView.ts new file mode 100644 index 0000000000..370aac57ad --- /dev/null +++ b/packages/lit-components/src/components/ProgramMenu/ProgramMenuView.ts @@ -0,0 +1,23 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { ProgramMenuItem, ProgramMenuProps } from './ProgramMenu'; +import { useProgramMenu } from './useProgramMenu'; + +export function ProgramMenuView(props: ProgramMenuProps & ReturnType) { + return html` + + ${UI.Select({ + value: props.selected, + onChange: (event: Event) => props.onSelect((event.target as HTMLInputElement).value), + disabled: props.loading, + children: html`${props.programs.map( + (program: ProgramMenuItem) => + html`${UI.Option({ value: program.programId, children: program.program?.name || program.programId })}` + )}`, + })} + `; +} diff --git a/packages/lit-components/src/components/ProgramMenu/index.ts b/packages/lit-components/src/components/ProgramMenu/index.ts new file mode 100644 index 0000000000..e347fd60ff --- /dev/null +++ b/packages/lit-components/src/components/ProgramMenu/index.ts @@ -0,0 +1 @@ +export * from './ProgramMenu'; diff --git a/packages/lit-components/src/components/ProgramMenu/useProgramMenu.test.ts b/packages/lit-components/src/components/ProgramMenu/useProgramMenu.test.ts new file mode 100644 index 0000000000..ef61c6970b --- /dev/null +++ b/packages/lit-components/src/components/ProgramMenu/useProgramMenu.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { + viewer: { + programShareLinks: [ + { programId: 'p1', program: { name: 'Program 1' }, shareLink: 'https://example.com/1' }, + { programId: 'p2', program: { name: 'Program 2' }, shareLink: 'https://example.com/2' }, + ], + }, + }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => ({ + setters: [] as Array>, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + universalHooksMock.setters.push(setter); + return [initial, setter] as const; + }), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useProgramMenu } from './useProgramMenu'; + +describe('useProgramMenu', () => { + beforeEach(() => { + if (!globalThis.document) { + Object.defineProperty(globalThis, 'document', { + value: { dispatchEvent: () => true }, + configurable: true, + writable: true, + }); + } + if (!globalThis.CustomEvent) { + Object.defineProperty(globalThis, 'CustomEvent', { + value: class { + type: string; + detail: unknown; + constructor(type: string, init?: { detail?: unknown }) { + this.type = type; + this.detail = init?.detail; + } + }, + configurable: true, + }); + } + universalHooksMock.setters.length = 0; + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + viewer: { + programShareLinks: [ + { programId: 'p1', program: { name: 'Program 1' }, shareLink: 'https://example.com/1' }, + { programId: 'p2', program: { name: 'Program 2' }, shareLink: 'https://example.com/2' }, + ], + }, + }, + loading: false, + }); + }); + + it('returns programs from query data', () => { + const result = useProgramMenu({ programId: 'p1' } as any); + + expect(result.programs).toHaveLength(2); + expect(result.programs[0]?.programId).toBe('p1'); + }); + + it('uses props.programId as the initial selected value', () => { + const result = useProgramMenu({ programId: 'p2' } as any); + + expect(result.selected).toBe('p2'); + }); + + it('dispatches a program select event on select', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent').mockReturnValue(true); + const result = useProgramMenu({ programId: 'p1' } as any); + + result.onSelect('p2'); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0]?.[0] as any; + expect(event.type).toBe('sq:program-select'); + expect(event.detail).toEqual({ programId: 'p2' }); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useProgramMenu({ programId: 'p1' } as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/ProgramMenu/useProgramMenu.ts b/packages/lit-components/src/components/ProgramMenu/useProgramMenu.ts new file mode 100644 index 0000000000..37cf267b36 --- /dev/null +++ b/packages/lit-components/src/components/ProgramMenu/useProgramMenu.ts @@ -0,0 +1,62 @@ +import { useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { ProgramMenuProps } from './ProgramMenu'; + +const PROGRAMS_QUERY = gql` + query getPrograms { + viewer { + ... on User { + programShareLinks { + programId + program { + name + } + shareLink + } + } + } + } +`; + +export function useProgramMenu(props: ProgramMenuProps) { + const user = useUserIdentity(); + const { data, loading } = useQuery(PROGRAMS_QUERY, {}, !user?.jwt); + const [selected, setSelected] = useState(props.programId || ''); + + const programs = data?.viewer?.programShareLinks || []; + + function onSelect(programId: string) { + setSelected(programId); + const event = new CustomEvent('sq:program-select', { + bubbles: true, + composed: true, + detail: { programId }, + }); + document.dispatchEvent(event); + } + + return { programs, selected, loading, onSelect }; +} + +export function useDemoProgramMenu(props: ProgramMenuProps): ReturnType { + const [selected, setSelected] = useState(props.programId || 'demo-program-1'); + const programs = [ + { + programId: 'demo-program-1', + program: { name: 'Referral Program' }, + shareLink: 'https://example.com/referral', + }, + { + programId: 'demo-program-2', + program: { name: 'Partner Program' }, + shareLink: 'https://example.com/partner', + }, + ]; + + function onSelect(programId: string) { + setSelected(programId); + } + + return { programs, selected, loading: false, onSelect }; +} diff --git a/packages/lit-components/src/components/QRCode/QRCode.stories.ts b/packages/lit-components/src/components/QRCode/QRCode.stories.ts new file mode 100644 index 0000000000..104a3ce90a --- /dev/null +++ b/packages/lit-components/src/components/QRCode/QRCode.stories.ts @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { QRCodeView } from './QRCodeView'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = '******'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: '******', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/QRCode', + component: 'sql-qr-code', + tags: ['autodocs'], + argTypes: { + size: { control: 'number' }, + 'program-id': { control: 'text' }, + 'background-color': { control: 'color' }, + 'foreground-color': { control: 'color' }, + }, + render: (args) => { + setupGlobals(); + return html``; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomSize: Story = { + args: { + size: 280, + }, +}; + +export const CustomColors: Story = { + args: { + 'background-color': '#f8fafc', + 'foreground-color': '#2563eb', + }, +}; + +export const Loading: Story = { + render: () => QRCodeView({ size: 200, loading: true, qrUrl: '', shareLink: '' }), +}; diff --git a/packages/lit-components/src/components/QRCode/QRCode.ts b/packages/lit-components/src/components/QRCode/QRCode.ts new file mode 100644 index 0000000000..588e23c132 --- /dev/null +++ b/packages/lit-components/src/components/QRCode/QRCode.ts @@ -0,0 +1,36 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { QRCodeView } from './QRCodeView'; +import { useDemoQRCode, useQRCode } from './useQRCode'; + +export interface QRCodeProps { + size: number; + programId?: string; + backgroundColor?: string; + foregroundColor?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-qr-code': HTMLElement; + } +} + +export const QRCode = useComponent( + (host) => { + const rawProps = getProps(host) as Partial; + const props: QRCodeProps = { + size: rawProps.size ? Number(rawProps.size) : 200, + programId: rawProps.programId, + backgroundColor: rawProps.backgroundColor || '#ffffff', + foregroundColor: rawProps.foregroundColor || '#000000', + }; + + const hookProps = isDemo() ? useDemoQRCode(props) : useQRCode(props); + + return QRCodeView({ ...props, ...hookProps }); + }, + 'sql-qr-code', + ['size', 'program-id', 'background-color', 'foreground-color'] as const +); diff --git a/packages/lit-components/src/components/QRCode/QRCodeView.ts b/packages/lit-components/src/components/QRCode/QRCodeView.ts new file mode 100644 index 0000000000..bb3a98fd4d --- /dev/null +++ b/packages/lit-components/src/components/QRCode/QRCodeView.ts @@ -0,0 +1,39 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { QRCodeProps } from './QRCode'; +import { useQRCode } from './useQRCode'; + +export function QRCodeView(props: QRCodeProps & ReturnType) { + const size = Number(props.size) || 200; + + return html` + +
+ ${props.loading + ? html`${UI.Spinner({ style: 'font-size: 2rem;' })}` + : props.qrUrl + ? html`QR Code` + : html`
+ ${UI.Icon({ name: 'qr-code', style: 'font-size: 3rem; color: var(--sl-color-neutral-400);' })} +
`} +
+ `; +} diff --git a/packages/lit-components/src/components/QRCode/index.ts b/packages/lit-components/src/components/QRCode/index.ts new file mode 100644 index 0000000000..a814ca01ed --- /dev/null +++ b/packages/lit-components/src/components/QRCode/index.ts @@ -0,0 +1 @@ +export * from './QRCode'; diff --git a/packages/lit-components/src/components/QRCode/useQRCode.test.ts b/packages/lit-components/src/components/QRCode/useQRCode.test.ts new file mode 100644 index 0000000000..b8ea19ea55 --- /dev/null +++ b/packages/lit-components/src/components/QRCode/useQRCode.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { user: { shareLink: 'https://example.com/share?code=abc' } }, + loading: false, + })), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { useQRCode } from './useQRCode'; + +describe('useQRCode', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { shareLink: 'https://example.com/share?code=abc' } }, + loading: false, + }); + }); + + it('returns a qr url with the default parameters', () => { + const result = useQRCode({} as any); + + expect(result.qrUrl).toBe( + 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https%3A%2F%2Fexample.com%2Fshare%3Fcode%3Dabc&bgcolor=ffffff&color=000000' + ); + }); + + it('returns an empty qr url when there is no share link', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: { user: { shareLink: '' } }, loading: false }); + + const result = useQRCode({} as any); + + expect(result.qrUrl).toBe(''); + }); + + it('returns the share link from query data', () => { + const result = useQRCode({} as any); + + expect(result.shareLink).toBe('https://example.com/share?code=abc'); + }); + + it('uses props for size and colors', () => { + const result = useQRCode({ + size: '300', + backgroundColor: '#112233', + foregroundColor: '#445566', + } as any); + + expect(result.qrUrl).toContain('size=300x300'); + expect(result.qrUrl).toContain('bgcolor=112233'); + expect(result.qrUrl).toContain('color=445566'); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useQRCode({} as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/QRCode/useQRCode.ts b/packages/lit-components/src/components/QRCode/useQRCode.ts new file mode 100644 index 0000000000..4974c6c4ea --- /dev/null +++ b/packages/lit-components/src/components/QRCode/useQRCode.ts @@ -0,0 +1,39 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { gql } from 'graphql-request'; +import { QRCodeProps } from './QRCode'; + +const QR_CODE_QUERY = gql` + query getShareLink($programId: ID) { + user: viewer { + ... on User { + shareLink(programId: $programId) + } + } + } +`; + +export function useQRCode(props: QRCodeProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const { data, loading } = useQuery(QR_CODE_QUERY, { programId }, !user?.jwt); + + const shareLink = data?.user?.shareLink || ''; + const size = Number(props.size) || 200; + const bg = (props.backgroundColor || '#ffffff').replace('#', ''); + const fg = (props.foregroundColor || '#000000').replace('#', ''); + const qrUrl = shareLink + ? `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodeURIComponent(shareLink)}&bgcolor=${bg}&color=${fg}` + : ''; + + return { qrUrl, shareLink, loading }; +} + +export function useDemoQRCode(props: QRCodeProps) { + const shareLink = 'https://www.example.com/sharelink/abc'; + const size = Number(props.size) || 200; + const bg = (props.backgroundColor || '#ffffff').replace('#', ''); + const fg = (props.foregroundColor || '#000000').replace('#', ''); + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodeURIComponent(shareLink)}&bgcolor=${bg}&color=${fg}`; + + return { qrUrl, shareLink, loading: false }; +} diff --git a/packages/lit-components/src/components/RadioCard/RadioCard.stories.ts b/packages/lit-components/src/components/RadioCard/RadioCard.stories.ts new file mode 100644 index 0000000000..cc422e87c7 --- /dev/null +++ b/packages/lit-components/src/components/RadioCard/RadioCard.stories.ts @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/RadioCard', + component: 'sql-radio-card', + tags: ['autodocs'], + argTypes: { + 'field-name': { control: 'text' }, + 'field-value': { control: 'text' }, + 'field-label': { control: 'text' }, + 'field-description': { control: 'text' }, + 'field-disabled': { control: 'boolean' }, + 'field-checked': { control: 'boolean' }, + icon: { control: 'text' }, + }, + render: (args) => html``, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + 'field-name': 'plan', + 'field-value': 'basic', + 'field-label': 'Basic Plan', + }, +}; + +export const Selected: Story = { + args: { + 'field-name': 'plan', + 'field-value': 'pro', + 'field-label': 'Pro Plan', + 'field-checked': true, + }, +}; + +export const WithIcon: Story = { + args: { + 'field-name': 'delivery', + 'field-value': 'plane', + 'field-label': 'Express Delivery', + icon: 'airplane', + }, +}; + +export const WithDescription: Story = { + args: { + 'field-name': 'support', + 'field-value': 'premium', + 'field-label': 'Premium Support', + 'field-description': '24/7 access to priority support and onboarding help.', + }, +}; + +export const Disabled: Story = { + args: { + 'field-name': 'plan', + 'field-value': 'enterprise', + 'field-label': 'Enterprise Plan', + 'field-disabled': true, + }, +}; + +export const Group: Story = { + render: () => html` +
+ + + +
+ `, +}; diff --git a/packages/lit-components/src/components/RadioCard/RadioCard.ts b/packages/lit-components/src/components/RadioCard/RadioCard.ts new file mode 100644 index 0000000000..c6bfcd9072 --- /dev/null +++ b/packages/lit-components/src/components/RadioCard/RadioCard.ts @@ -0,0 +1,60 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { RadioCardView } from './RadioCardView'; +import { useRadioCard } from './useRadioCard'; + +export interface RadioCardProps { + fieldName: string; + fieldValue: string; + fieldLabel?: string; + fieldDescription?: string; + fieldDisabled: boolean; + fieldChecked: boolean; + icon?: string; + onClick?: () => void; +} + +function getBooleanAttribute(host: HTMLElement, name: string, defaultValue = false) { + if (!host.hasAttribute(name)) { + return defaultValue; + } + + const value = host.getAttribute(name); + return value === '' || value === 'true'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-radio-card': HTMLElement; + } +} + +export const RadioCard = useComponent( + (host) => { + const rawProps = getProps(host); + const props: RadioCardProps = { + fieldName: rawProps.fieldName || '', + fieldValue: rawProps.fieldValue || '', + fieldLabel: rawProps.fieldLabel || undefined, + fieldDescription: rawProps.fieldDescription || undefined, + fieldDisabled: getBooleanAttribute(host, 'field-disabled', false), + fieldChecked: getBooleanAttribute(host, 'field-checked', false), + icon: rawProps.icon || undefined, + onClick: (host as HTMLElement & { onClick?: () => void }).onClick, + }; + + const hookProps = useRadioCard(props); + + return RadioCardView({ ...props, ...hookProps }); + }, + 'sql-radio-card', + [ + 'field-name', + 'field-value', + 'field-label', + 'field-description', + 'field-disabled', + 'field-checked', + 'icon', + ] as const +); diff --git a/packages/lit-components/src/components/RadioCard/RadioCardView.ts b/packages/lit-components/src/components/RadioCard/RadioCardView.ts new file mode 100644 index 0000000000..88915a4cf6 --- /dev/null +++ b/packages/lit-components/src/components/RadioCard/RadioCardView.ts @@ -0,0 +1,81 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { RadioCardProps } from './RadioCard'; +import { useRadioCard } from './useRadioCard'; + +const styles = ` + :host { + display: block; + } + + .radio-card { + display: flex; + align-items: center; + gap: var(--sl-spacing-medium); + padding: var(--sl-spacing-medium); + border: 2px solid var(--sl-color-neutral-200); + border-radius: var(--sl-border-radius-medium); + cursor: pointer; + transition: border-color 0.2s; + } + + .radio-card:hover { + border-color: var(--sl-color-primary-300); + } + + .radio-card.selected { + border-color: var(--sl-color-primary-600); + background: var(--sl-color-primary-50); + } + + .radio-card.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .radio-card input[type='radio'] { + display: none; + } + + .radio-card-content { + display: flex; + flex-direction: column; + gap: var(--sl-spacing-2x-small); + } + + .radio-card-label { + font-weight: var(--sl-font-weight-semibold); + } + + .radio-card-description { + font-size: var(--sl-font-size-small); + color: var(--sl-color-neutral-500); + } +`; + +export function RadioCardView(props: RadioCardProps & ReturnType) { + return html` + +
+ + ${props.icon ? html`${UI.Icon({ name: props.icon })}` : ''} +
+ ${props.fieldLabel || ''} + ${props.fieldDescription + ? html`${props.fieldDescription}` + : ''} +
+
+ `; +} diff --git a/packages/lit-components/src/components/RadioCard/index.ts b/packages/lit-components/src/components/RadioCard/index.ts new file mode 100644 index 0000000000..e1128e9eca --- /dev/null +++ b/packages/lit-components/src/components/RadioCard/index.ts @@ -0,0 +1 @@ +export * from './RadioCard'; diff --git a/packages/lit-components/src/components/RadioCard/useRadioCard.test.ts b/packages/lit-components/src/components/RadioCard/useRadioCard.test.ts new file mode 100644 index 0000000000..cef20d5f9d --- /dev/null +++ b/packages/lit-components/src/components/RadioCard/useRadioCard.test.ts @@ -0,0 +1,20 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useRadioCard } from './useRadioCard'; + +describe('useRadioCard', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should return an empty object', () => { + const result = useRadioCard({ + fieldName: 'plan', + fieldValue: 'pro', + fieldDisabled: false, + fieldChecked: false, + }); + + expect(result).toEqual({}); + }); +}); diff --git a/packages/lit-components/src/components/RadioCard/useRadioCard.ts b/packages/lit-components/src/components/RadioCard/useRadioCard.ts new file mode 100644 index 0000000000..9e63a6d1b0 --- /dev/null +++ b/packages/lit-components/src/components/RadioCard/useRadioCard.ts @@ -0,0 +1,5 @@ +import { RadioCardProps } from './RadioCard'; + +export function useRadioCard(_props: RadioCardProps) { + return {}; +} diff --git a/packages/lit-components/src/components/ReferralCard/ReferralCard.stories.ts b/packages/lit-components/src/components/ReferralCard/ReferralCard.stories.ts new file mode 100644 index 0000000000..a5922c8ba3 --- /dev/null +++ b/packages/lit-components/src/components/ReferralCard/ReferralCard.stories.ts @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/ReferralCard', + component: 'sql-referral-card', + tags: ['autodocs'], + argTypes: { + padding: { control: 'select', options: ['none', 'small', 'medium', 'large'] }, + 'background-color': { control: 'color' }, + 'border-color': { control: 'color' }, + 'border-radius': { control: 'number' }, + }, + render: (args) => + html` +
+

Referral Card

+

Use this card to wrap referral content.

+
+
`, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomPadding: Story = { + args: { + padding: 'large', + }, +}; + +export const CustomColors: Story = { + args: { + 'background-color': '#f8fafc', + 'border-color': '#cbd5e1', + }, +}; + +export const NoBorder: Story = { + args: { + 'border-color': 'transparent', + }, +}; + +export const Rounded: Story = { + args: { + 'border-radius': 24, + }, +}; diff --git a/packages/lit-components/src/components/ReferralCard/ReferralCard.ts b/packages/lit-components/src/components/ReferralCard/ReferralCard.ts new file mode 100644 index 0000000000..e8447a96cf --- /dev/null +++ b/packages/lit-components/src/components/ReferralCard/ReferralCard.ts @@ -0,0 +1,36 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ReferralCardView } from './ReferralCardView'; + +export interface ReferralCardProps { + padding: 'none' | 'small' | 'medium' | 'large'; + backgroundColor?: string; + borderColor?: string; + borderRadius?: number; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-referral-card': HTMLElement; + } +} + +export const ReferralCard = useComponent( + (host) => { + const rawProps = getProps(host) as Partial; + const padding = rawProps.padding; + const props: ReferralCardProps = { + padding: + padding === 'none' || padding === 'small' || padding === 'medium' || padding === 'large' + ? padding + : 'medium', + backgroundColor: rawProps.backgroundColor, + borderColor: rawProps.borderColor, + borderRadius: rawProps.borderRadius ? Number(rawProps.borderRadius) : undefined, + }; + + return ReferralCardView(props); + }, + 'sql-referral-card', + ['padding', 'background-color', 'border-color', 'border-radius'] as const +); diff --git a/packages/lit-components/src/components/ReferralCard/ReferralCardView.ts b/packages/lit-components/src/components/ReferralCard/ReferralCardView.ts new file mode 100644 index 0000000000..ab2d5786c3 --- /dev/null +++ b/packages/lit-components/src/components/ReferralCard/ReferralCardView.ts @@ -0,0 +1,28 @@ +import { html } from 'lit'; +import { ReferralCardProps } from './ReferralCard'; + +export function ReferralCardView(props: ReferralCardProps) { + const padding = props.padding === 'none' ? '0' : `var(--sl-spacing-${props.padding || 'medium'})`; + const borderRadius = props.borderRadius + ? `${props.borderRadius}px` + : 'var(--sl-border-radius-large)'; + + return html` + +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/ReferralCard/index.ts b/packages/lit-components/src/components/ReferralCard/index.ts new file mode 100644 index 0000000000..2259a80add --- /dev/null +++ b/packages/lit-components/src/components/ReferralCard/index.ts @@ -0,0 +1 @@ +export * from './ReferralCard'; diff --git a/packages/lit-components/src/components/ReferralCode/ReferralCode.stories.ts b/packages/lit-components/src/components/ReferralCode/ReferralCode.stories.ts new file mode 100644 index 0000000000..c9cd16083a --- /dev/null +++ b/packages/lit-components/src/components/ReferralCode/ReferralCode.stories.ts @@ -0,0 +1,173 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IklSTVhzWXk2WVlxcTQ2OTQzN21HOEVSUXQ4UW9LRkJhRzEifQ.eyJ1c2VyIjp7ImlkIjoidGVzdHVzZXIiLCJhY2NvdW50SWQiOiJ0ZXN0dXNlciIsImVtYWlsIjoidGVzdHVzZXJAZXhhbXBsZS5jb20ifX0.tegzTaLms4g47rwcWoyhk1WW4hqB16PulQV9zouJNfU'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IklSTVhzWXk2WVlxcTQ2OTQzN21HOEVSUXQ4UW9LRkJhRzEifQ.eyJ1c2VyIjp7ImlkIjoidGVzdHVzZXIiLCJhY2NvdW50SWQiOiJ0ZXN0dXNlciIsImVtYWlsIjoidGVzdHVzZXJAZXhhbXBsZS5jb20ifX0.tegzTaLms4g47rwcWoyhk1WW4hqB16PulQV9zouJNfU', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/ReferralCode', + component: 'sql-referral-code', + tags: ['autodocs'], + argTypes: { + 'tooltip-text': { control: 'text' }, + 'show-notification-text': { control: 'boolean' }, + 'notification-text': { control: 'text' }, + 'tooltip-lifespan': { control: 'number' }, + 'text-align': { control: 'select', options: ['left', 'center', 'right'] }, + 'copy-button-label': { control: 'text' }, + 'copy-button-style': { control: 'select', options: ['icon', 'button-outside', 'button-below'] }, + 'background-color': { control: 'color' }, + 'border-color': { control: 'color' }, + 'text-color': { control: 'color' }, + 'border-radius': { control: 'number' }, + 'button-type': { control: 'select', options: ['primary', 'secondary'] }, + }, + render: (args) => { + setupGlobals(); + return html``; + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * Default referral code component with icon button + */ +export const Default: Story = { + args: { + 'tooltip-text': 'Copied to Clipboard', + 'copy-button-label': 'Copy Code', + 'copy-button-style': 'icon', + 'button-type': 'primary', + }, +}; + +/** + * Button style outside the input + */ +export const ButtonOutside: Story = { + args: { + 'copy-button-style': 'button-outside', + }, +}; + +/** + * Button style below the input + */ +export const ButtonBelow: Story = { + args: { + 'copy-button-style': 'button-below', + }, +}; + +/** + * Custom button label + */ +export const CustomButtonLabel: Story = { + args: { + 'copy-button-style': 'button-outside', + 'copy-button-label': 'Copy My Code', + }, +}; + +/** + * Secondary button type + */ +export const SecondaryButton: Story = { + args: { + 'copy-button-style': 'button-outside', + 'button-type': 'secondary', + }, +}; + +/** + * Custom colors + */ +export const CustomColors: Story = { + args: { + 'background-color': '#f0f8ff', + 'border-color': '#4169e1', + 'text-color': '#191970', + }, +}; + +/** + * Custom border radius + */ +export const CustomBorderRadius: Story = { + args: { + 'border-radius': 20, + }, +}; + +/** + * With notification text shown + */ +export const WithNotification: Story = { + args: { + 'show-notification-text': true, + 'notification-text': 'This code has been copied', + }, +}; + +/** + * Custom tooltip text + */ +export const CustomTooltip: Story = { + args: { + 'tooltip-text': '✓ Code copied!', + }, +}; + +/** + * All customizations combined + */ +export const FullyCustomized: Story = { + args: { + 'copy-button-style': 'button-below', + 'copy-button-label': 'Copy Referral Code', + 'button-type': 'primary', + 'background-color': '#e8f5e9', + 'border-color': '#4caf50', + 'text-color': '#1b5e20', + 'border-radius': 12, + 'tooltip-text': 'Successfully copied!', + 'show-notification-text': true, + 'notification-text': 'Remember to share this code with your friends', + }, +}; diff --git a/packages/lit-components/src/components/ReferralCode/ReferralCode.ts b/packages/lit-components/src/components/ReferralCode/ReferralCode.ts new file mode 100644 index 0000000000..bfc6bf4aee --- /dev/null +++ b/packages/lit-components/src/components/ReferralCode/ReferralCode.ts @@ -0,0 +1,194 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { useDemoReferralCode, useReferralCode } from './useReferralCode'; +import { ReferralCodeView } from './ReferralCodeView'; + +export interface ReferralCodeProps { + /** + * Text displayed in the tooltip when the code is copied + * + * @uiName Tooltip text + * @uiGroup Content + * @uiDefault "Copied to Clipboard" + */ + tooltipText: string; + + /** + * Whether to show notification text when code is copied + * + * @uiName Show notification + * @uiGroup Content + * @uiDefault true + */ + showNotificationText: boolean; + + /** + * Text displayed below the input after copying + * + * @uiName Notification text + * @uiGroup Content + * @uiDefault "You've copied this code before" + */ + notificationText: string; + + /** + * Duration in milliseconds that the tooltip is displayed + * + * @uiName Tooltip duration + * @uiGroup Behavior + * @uiDefault 1000 + */ + tooltipLifespan: number; + + /** + * Change the text alignment + * + * @uiName Referral code alignment + * @uiEnum ["left", "center", "right"] + * @uiEnumNames ["Left", "Center", "Right"] + * @uiGroup Style + * @uiDefault "left" + */ + textAlign: string; + + /** + * Label text for the copy button + * + * @uiName Copy button label + * @uiGroup Content + * @uiDefault "Copy Code" + */ + copyButtonLabel: string; + + /** + * Visual style of the copy button + * + * @uiName Button style + * @uiEnum ["icon", "button-outside", "button-below"] + * @uiEnumNames ["Icon", "Button Outside", "Button Below"] + * @uiGroup Style + * @uiDefault "icon" + */ + buttonStyle: 'icon' | 'button-outside' | 'button-below'; + + /** + * Color variant of the button + * + * @uiName Button type + * @uiEnum ["primary", "secondary"] + * @uiEnumNames ["Primary", "Secondary"] + * @uiGroup Style + * @uiDefault "primary" + */ + buttonType: 'primary' | 'secondary'; + + /** + * Background color of the input field + * + * @uiName Background color + * @uiWidget color + * @uiGroup Colors + */ + backgroundColor?: string; + + /** + * Border color of the input field + * + * @uiName Border color + * @uiWidget color + * @uiGroup Colors + */ + borderColor?: string; + + /** + * Text color within the input field + * + * @uiName Text color + * @uiWidget color + * @uiGroup Colors + */ + textColor?: string; + + /** + * Border radius in pixels + * + * @uiName Border radius + * @uiGroup Style + */ + borderRadius?: number; + + /** + * Program identifier for referral code lookup + * + * @uiName Program ID + * @uiGroup Configuration + */ + programId?: string; + + /** + * Placeholder text shown when there's an error + * + * @uiName Error placeholder + * @uiGroup Content + */ + inputPlaceholderText?: string; + + /** + * Error message text + * + * @uiName Error message + * @uiGroup Content + */ + errorText?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-referral-code': HTMLElement; + } +} + +/** + * @uiName Referral Code + * @validParents ["sqm-portal-container","div","sqm-divided-layout","sqm-brand","template","sqm-referral-card","span", "sqm-referral-codes"] + * @exampleGroup Sharing + * @example Referral Code - + */ +export const ReferralCode = useComponent( + (host) => { + const props: ReferralCodeProps = { + tooltipText: 'Copied to Clipboard', + showNotificationText: false, + notificationText: "You've copied this code before", + tooltipLifespan: 1000, + textAlign: 'left', + copyButtonLabel: 'Copy Code', + buttonStyle: 'icon', + buttonType: 'primary', + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoReferralCode(props) : useReferralCode(props); + + console.log({ props, hookProps }); + + return ReferralCodeView({ ...props, ...hookProps }); + }, + 'sql-referral-code', + [ + 'tooltip-text', + 'show-notification-text', + 'notification-text', + 'tooltip-lifespan', + 'text-align', + 'copy-button-label', + 'copy-button-style', + 'background-color', + 'border-color', + 'text-color', + 'border-radius', + 'button-type', + 'program-id', + ] as const +); diff --git a/packages/lit-components/src/components/ReferralCode/ReferralCodeView.ts b/packages/lit-components/src/components/ReferralCode/ReferralCodeView.ts new file mode 100644 index 0000000000..f60f4d215a --- /dev/null +++ b/packages/lit-components/src/components/ReferralCode/ReferralCodeView.ts @@ -0,0 +1,150 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { ButtonVariant } from '../../ui/types'; +import { ReferralCodeProps } from './ReferralCode'; +import { useReferralCode } from './useReferralCode'; + +/** + * Base static styles that don't change + */ +const baseStyles = ` + :host { + display: block; + } + + sl-input { + width: 100%; + } + + .container-div { + display: flex; + align-items: flex-start; + flex-direction: column; + justify-content: center; + gap: var(--sl-spacing-x-small); + width: 100%; + } + + .container-style { + display: flex; + align-items: center; + gap: var(--sl-spacing-x-small); + width: 100%; + } + + .error-text { + margin: 0; + color: var(--sqm-danger-color-text); + } + + .notification-text { + margin: 0; + color: inherit; + } +`; + +/** + * Generate dynamic styles based on props + */ +const getDynamicStyles = (props: ReferralCodeProps) => ` + sl-input::part(input) { + text-overflow: ellipsis; + width: 100%; + color: ${props.textColor || 'var(--sl-input-color)'}; + } + + sl-input::part(base) { + --sl-input-border-radius: ${props.borderRadius ? `${props.borderRadius}px` : 'var(--sqm-border-radius-normal)'}; + --sl-input-border-color: var(--sqm-border-color); + --sl-input-border-color-hover: #999999; + --sl-input-border-color-focus: #999999; + --sl-input-color-hover: var(--sqm-input-color-hover); + --sl-input-color-focus: var(--sqm-input-color-focus); + --sl-input-color-disabled: var(--sqm-input-disabled-color); + --sl-input-background-color-focus: var(--sqm-input-background); + --sl-input-background-color-hover: var(--sqm-input-background); + cursor: pointer; + overflow: visible; + border-radius: ${props.borderRadius ? `${props.borderRadius}px` : 'var(--sqm-border-radius-normal)'}; + background: ${props.backgroundColor || 'var(--sqm-input-background)'}; + border: var(--sqm-border-thickness) solid ${props.borderColor || 'var(--sqm-input-border-color)'}; + } + + sl-icon::part(base) { + color: ${props.textColor || 'var(--sqm-text)'}; + } + + sl-icon-button::part(base) { + color: ${props.textColor || 'var(--sqm-text)'}; + } +`; + +export function ReferralCodeView(props: ReferralCodeProps & ReturnType) { + const dynamicStyles = getDynamicStyles(props); + + const buttonStyle = props.buttonStyle || 'icon'; + const error = !props.loading && props.error; + const inputText = error ? props.inputPlaceholderText : props.copyString; + const disabled = !!(error || props.loading || props.disabled); + const tooltipPlacement = + props.buttonStyle === 'button-below' + ? 'bottom' + : props.buttonStyle === 'button-outside' + ? 'top' + : 'top-end'; + + const copyButton = html` + ${UI.Tooltip({ + trigger: 'manual', + content: props.tooltipText, + placement: tooltipPlacement, + disabled: !!props.disabled, + open: props.open, + skidding: props.buttonStyle === 'icon' ? -5 : 0, + slot: 'suffix', + children: buttonStyle === 'icon' + ? UI.IconButton({ + exportparts: 'base: icon-button-base', + onClick: () => props.onClick?.(), + name: 'files', + disabled: disabled, + }) + : UI.Button({ + exportparts: `base: ${props.buttonType || 'primary'}button-base`, + onClick: () => props.onClick?.(), + size: 'medium', + style: buttonStyle === 'button-below' ? 'width: 100%' : '', + disabled: disabled, + variant: (props.buttonType || 'primary') as ButtonVariant, + children: props.copyButtonLabel || 'Copy', + }), + })} + `; + + return html` + +
+
+ ${UI.Input({ + value: props.loading ? 'Loading...' : inputText, + readonly: true, + disabled: disabled, + suffix: buttonStyle === 'icon' ? copyButton : undefined, + error: error ? html`

${props.errorText}

` : undefined, + })} + ${buttonStyle === 'button-outside' || buttonStyle === 'button-below' ? copyButton : ''} +
+ ${props.isCopied && props.showNotificationText && props.notificationText + ? html` +

${props.notificationText}

+ ` + : ''} +
+ `; +} diff --git a/packages/lit-components/src/components/ReferralCode/index.ts b/packages/lit-components/src/components/ReferralCode/index.ts new file mode 100644 index 0000000000..d211872b59 --- /dev/null +++ b/packages/lit-components/src/components/ReferralCode/index.ts @@ -0,0 +1,2 @@ +// OtherComponent main export +export * from './ReferralCode'; diff --git a/packages/lit-components/src/components/ReferralCode/useReferralCode.test.ts b/packages/lit-components/src/components/ReferralCode/useReferralCode.test.ts new file mode 100644 index 0000000000..731b61de02 --- /dev/null +++ b/packages/lit-components/src/components/ReferralCode/useReferralCode.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt', id: 'user1', accountId: 'acc1' })), + useEngagementMedium: vi.fn(() => 'EMBED'), + useQuery: vi.fn(() => ({ data: { user: { referralCode: 'TESTCODE123' } }, loading: false })), + useMutation: vi.fn(() => [vi.fn()]), +})); + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useReferralCode } from './useReferralCode'; + +describe('useReferralCode', () => { + it('should return referral code from query', () => { + const result = useReferralCode({ tooltipLifespan: 1000, programId: '' } as any); + + expect(result.copyString).toBe('TESTCODE123'); + expect(result.loading).toBe(false); + expect(result.disabled).toBe(false); + }); + + it('should have onClick handler', () => { + const result = useReferralCode({ tooltipLifespan: 1000, programId: '' } as any); + + expect(typeof result.onClick).toBe('function'); + }); + + it('should not have error by default', () => { + const result = useReferralCode({ tooltipLifespan: 1000, programId: '' } as any); + + expect(result.error).toBe(''); + }); +}); diff --git a/packages/lit-components/src/components/ReferralCode/useReferralCode.ts b/packages/lit-components/src/components/ReferralCode/useReferralCode.ts new file mode 100644 index 0000000000..d63fbea4c0 --- /dev/null +++ b/packages/lit-components/src/components/ReferralCode/useReferralCode.ts @@ -0,0 +1,105 @@ +import { + useEngagementMedium, + useMutation, + useProgramId, + useQuery, + useUserIdentity, +} from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { ReferralCodeProps } from './ReferralCode'; + +const MessageLinkQuery = gql` + query getReferralCode($programId: ID) { + user: viewer { + ... on User { + referralCode(programId: $programId) + } + } + } +`; + +const WIDGET_ENGAGEMENT_EVENT = gql` + mutation loadEvent($eventMeta: UserAnalyticsEvent!) { + createUserAnalyticsEvent(eventMeta: $eventMeta) + } +`; + +export const SET_CODE_COPIED = gql` + mutation markReferralCodeCopied($referralCode: String!) { + markReferralCodeCopied(referralCode: $referralCode) { + referralCode { + dateCopied + } + } + } +`; + +export function useReferralCode(props: ReferralCodeProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const engagementMedium = useEngagementMedium(); + + const { data, loading } = useQuery(MessageLinkQuery, { programId }, !user?.jwt); + + const [sendLoadEvent] = useMutation(WIDGET_ENGAGEMENT_EVENT); + // const [setCopied] = useMutation(SET_CODE_COPIED); + + const copyString = + data?.user?.referralCode ?? + // Shown during loading + '...'; + + const [open, setOpen] = useState(false); + + async function onClick() { + // Should well supported: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard#browser_compatibility + // Only if called from a user-initiated event + navigator.clipboard.writeText(copyString); + setOpen(true); + setTimeout(() => setOpen(false), props.tooltipLifespan); + sendLoadEvent({ + eventMeta: { + programId, + id: user?.id, + accountId: user?.accountId, + type: 'USER_REFERRAL_PROGRAM_ENGAGEMENT_EVENT', + meta: { + engagementMedium, + shareMedium: 'DIRECT', + }, + }, + }); + } + + return { + onClick, + open, + disabled: loading, + loading, + copyString: copyString, + error: '', + isCopied: false, + }; +} + +export function useDemoReferralCode(props: ReferralCodeProps) { + const [open, setOpen] = useState(false); + const copyString = 'REFERRALCODE001'; + + function onClick() { + navigator.clipboard.writeText(copyString); + setOpen(true); + setTimeout(() => setOpen(false), props.tooltipLifespan); + } + + return { + onClick, + open, + disabled: false, + loading: false, + copyString, + error: '', + isCopied: props.showNotificationText, + }; +} diff --git a/packages/lit-components/src/components/ReferralCodes/ReferralCodes.stories.ts b/packages/lit-components/src/components/ReferralCodes/ReferralCodes.stories.ts new file mode 100644 index 0000000000..a73c9ae3bf --- /dev/null +++ b/packages/lit-components/src/components/ReferralCodes/ReferralCodes.stories.ts @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { ReferralCodesView } from './ReferralCodesView'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = '******'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: '******', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/ReferralCodes', + component: 'sql-referral-codes', + tags: ['autodocs'], + render: () => { + setupGlobals(); + return html` + + + + + `; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + render: () => ReferralCodesView({ loading: true, codes: [] }), +}; + +export const Empty: Story = { + render: () => ReferralCodesView({ loading: false, codes: [] }), +}; diff --git a/packages/lit-components/src/components/ReferralCodes/ReferralCodes.ts b/packages/lit-components/src/components/ReferralCodes/ReferralCodes.ts new file mode 100644 index 0000000000..970bc2e89b --- /dev/null +++ b/packages/lit-components/src/components/ReferralCodes/ReferralCodes.ts @@ -0,0 +1,29 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ReferralCodesView } from './ReferralCodesView'; +import { useDemoReferralCodes, useReferralCodes } from './useReferralCodes'; + +export interface ReferralCodesProps { + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-referral-codes': HTMLElement; + } +} + +export const ReferralCodes = useComponent( + (host) => { + const props: ReferralCodesProps = { + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoReferralCodes(props) : useReferralCodes(props); + + return ReferralCodesView({ ...props, ...hookProps }); + }, + 'sql-referral-codes', + ['program-id'] as const +); diff --git a/packages/lit-components/src/components/ReferralCodes/ReferralCodesView.ts b/packages/lit-components/src/components/ReferralCodes/ReferralCodesView.ts new file mode 100644 index 0000000000..2dc860ef3c --- /dev/null +++ b/packages/lit-components/src/components/ReferralCodes/ReferralCodesView.ts @@ -0,0 +1,22 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { ReferralCodesProps } from './ReferralCodes'; +import { useReferralCodes } from './useReferralCodes'; + +export function ReferralCodesView(props: ReferralCodesProps & ReturnType) { + return html` + +
+ ${props.loading ? html`${UI.Spinner({})}` : html``} +
+ `; +} diff --git a/packages/lit-components/src/components/ReferralCodes/index.ts b/packages/lit-components/src/components/ReferralCodes/index.ts new file mode 100644 index 0000000000..95c384ba43 --- /dev/null +++ b/packages/lit-components/src/components/ReferralCodes/index.ts @@ -0,0 +1 @@ +export * from './ReferralCodes'; diff --git a/packages/lit-components/src/components/ReferralCodes/useReferralCodes.test.ts b/packages/lit-components/src/components/ReferralCodes/useReferralCodes.test.ts new file mode 100644 index 0000000000..df3eb2ef20 --- /dev/null +++ b/packages/lit-components/src/components/ReferralCodes/useReferralCodes.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { + user: { + referralCodes: [ + { referralCode: 'ABC123', dateCreated: '2024-01-01', dateCopied: null }, + { referralCode: 'XYZ789', dateCreated: '2024-01-02', dateCopied: '2024-01-03' }, + ], + }, + }, + loading: false, + })), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { useReferralCodes } from './useReferralCodes'; + +describe('useReferralCodes', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + user: { + referralCodes: [ + { referralCode: 'ABC123', dateCreated: '2024-01-01', dateCopied: null }, + { referralCode: 'XYZ789', dateCreated: '2024-01-02', dateCopied: '2024-01-03' }, + ], + }, + }, + loading: false, + }); + }); + + it('returns referral codes from query data', () => { + const result = useReferralCodes({} as any); + + expect(result.codes).toHaveLength(2); + expect(result.codes[0]?.referralCode).toBe('ABC123'); + }); + + it('returns an empty array when data is missing', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: { user: {} }, loading: false }); + + const result = useReferralCodes({} as any); + + expect(result.codes).toEqual([]); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useReferralCodes({} as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/ReferralCodes/useReferralCodes.ts b/packages/lit-components/src/components/ReferralCodes/useReferralCodes.ts new file mode 100644 index 0000000000..0a2087e734 --- /dev/null +++ b/packages/lit-components/src/components/ReferralCodes/useReferralCodes.ts @@ -0,0 +1,36 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { gql } from 'graphql-request'; +import { ReferralCodesProps } from './ReferralCodes'; + +const ReferralCodesQuery = gql` + query getReferralCodes($programId: ID) { + user: viewer { + ... on User { + referralCodes(programId: $programId) { + referralCode + dateCreated + dateCopied + } + } + } + } +`; + +export function useReferralCodes(props: ReferralCodesProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const { data, loading } = useQuery(ReferralCodesQuery, { programId }, !user?.jwt); + + const codes = data?.user?.referralCodes || []; + return { codes, loading }; +} + +export function useDemoReferralCodes(_props: ReferralCodesProps) { + return { + codes: [ + { referralCode: 'ABC123', dateCreated: '2024-01-01', dateCopied: null }, + { referralCode: 'XYZ789', dateCreated: '2024-01-02', dateCopied: '2024-01-03' }, + ], + loading: false, + }; +} diff --git a/packages/lit-components/src/components/ReferralIframe/ReferralIframe.stories.ts b/packages/lit-components/src/components/ReferralIframe/ReferralIframe.stories.ts new file mode 100644 index 0000000000..2ded0fbccf --- /dev/null +++ b/packages/lit-components/src/components/ReferralIframe/ReferralIframe.stories.ts @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { ReferralIframeView } from './ReferralIframeView'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = '******'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: '******', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/ReferralIframe', + component: 'sql-referral-iframe', + tags: ['autodocs'], + argTypes: { + 'iframe-url': { control: 'text' }, + 'iframe-width': { control: 'text' }, + 'iframe-height': { control: 'text' }, + 'program-id': { control: 'text' }, + }, + render: (args) => { + setupGlobals(); + return html``; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomSize: Story = { + args: { + 'iframe-width': '100%', + 'iframe-height': '500px', + }, +}; + +export const Loading: Story = { + render: () => + ReferralIframeView({ + iframeUrl: 'https://example.com/referral', + iframeWidth: '100%', + iframeHeight: '400px', + url: '', + loading: true, + }), +}; diff --git a/packages/lit-components/src/components/ReferralIframe/ReferralIframe.ts b/packages/lit-components/src/components/ReferralIframe/ReferralIframe.ts new file mode 100644 index 0000000000..b85fe0e7e8 --- /dev/null +++ b/packages/lit-components/src/components/ReferralIframe/ReferralIframe.ts @@ -0,0 +1,35 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ReferralIframeView } from './ReferralIframeView'; +import { useDemoReferralIframe, useReferralIframe } from './useReferralIframe'; + +export interface ReferralIframeProps { + iframeUrl?: string; + iframeWidth: string; + iframeHeight: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-referral-iframe': HTMLElement; + } +} + +export const ReferralIframe = useComponent( + (host) => { + const props: ReferralIframeProps = { + iframeUrl: '', + iframeWidth: '100%', + iframeHeight: '400px', + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoReferralIframe(props) : useReferralIframe(props); + + return ReferralIframeView({ ...props, ...hookProps }); + }, + 'sql-referral-iframe', + ['iframe-url', 'iframe-width', 'iframe-height', 'program-id'] as const +); diff --git a/packages/lit-components/src/components/ReferralIframe/ReferralIframeView.ts b/packages/lit-components/src/components/ReferralIframe/ReferralIframeView.ts new file mode 100644 index 0000000000..4e0252c75d --- /dev/null +++ b/packages/lit-components/src/components/ReferralIframe/ReferralIframeView.ts @@ -0,0 +1,41 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { ReferralIframeProps } from './ReferralIframe'; +import { useReferralIframe } from './useReferralIframe'; + +export function ReferralIframeView( + props: ReferralIframeProps & ReturnType +) { + return html` + +
+ ${props.loading + ? html`${UI.Spinner({})}` + : props.url + ? html`` + : html`
+ No URL configured +
`} +
+ `; +} diff --git a/packages/lit-components/src/components/ReferralIframe/index.ts b/packages/lit-components/src/components/ReferralIframe/index.ts new file mode 100644 index 0000000000..b116d70dc3 --- /dev/null +++ b/packages/lit-components/src/components/ReferralIframe/index.ts @@ -0,0 +1 @@ +export * from './ReferralIframe'; diff --git a/packages/lit-components/src/components/ReferralIframe/useReferralIframe.test.ts b/packages/lit-components/src/components/ReferralIframe/useReferralIframe.test.ts new file mode 100644 index 0000000000..9e3d6ad40d --- /dev/null +++ b/packages/lit-components/src/components/ReferralIframe/useReferralIframe.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ data: { user: { shareLink: 'https://example.com/share?ref=123' } }, loading: false })), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { useReferralIframe } from './useReferralIframe'; + +describe('useReferralIframe', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { shareLink: 'https://example.com/share?ref=123' } }, + loading: false, + }); + }); + + it('returns the iframe url with the share link appended', () => { + const result = useReferralIframe({ iframeUrl: 'https://iframe.example.com' } as any); + + expect(result.url).toBe( + 'https://iframe.example.com?shareLink=https%3A%2F%2Fexample.com%2Fshare%3Fref%3D123' + ); + }); + + it('uses an ampersand when the iframe url already has query params', () => { + const result = useReferralIframe({ iframeUrl: 'https://iframe.example.com?lang=en' } as any); + + expect(result.url).toBe( + 'https://iframe.example.com?lang=en&shareLink=https%3A%2F%2Fexample.com%2Fshare%3Fref%3D123' + ); + }); + + it('returns an empty url when iframeUrl is missing', () => { + const result = useReferralIframe({ iframeUrl: '' } as any); + + expect(result.url).toBe(''); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useReferralIframe({ iframeUrl: 'https://iframe.example.com' } as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/ReferralIframe/useReferralIframe.ts b/packages/lit-components/src/components/ReferralIframe/useReferralIframe.ts new file mode 100644 index 0000000000..0d62699264 --- /dev/null +++ b/packages/lit-components/src/components/ReferralIframe/useReferralIframe.ts @@ -0,0 +1,35 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { gql } from 'graphql-request'; +import { ReferralIframeProps } from './ReferralIframe'; + +const IFRAME_QUERY = gql` + query getShareLink($programId: ID) { + user: viewer { + ... on User { + shareLink(programId: $programId) + } + } + } +`; + +export function useReferralIframe(props: ReferralIframeProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const { data, loading } = useQuery(IFRAME_QUERY, { programId }, !user?.jwt); + + const shareLink = data?.user?.shareLink || ''; + const url = props.iframeUrl + ? `${props.iframeUrl}${props.iframeUrl.includes('?') ? '&' : '?'}shareLink=${encodeURIComponent(shareLink)}` + : ''; + + return { url, loading }; +} + +export function useDemoReferralIframe(props: ReferralIframeProps) { + const shareLink = 'https://www.example.com/sharelink/abc'; + const url = props.iframeUrl + ? `${props.iframeUrl}${props.iframeUrl.includes('?') ? '&' : '?'}shareLink=${encodeURIComponent(shareLink)}` + : ''; + + return { url, loading: false }; +} diff --git a/packages/lit-components/src/components/ReferralTable/ReferralTable.stories.ts b/packages/lit-components/src/components/ReferralTable/ReferralTable.stories.ts new file mode 100644 index 0000000000..6cb7f12609 --- /dev/null +++ b/packages/lit-components/src/components/ReferralTable/ReferralTable.stories.ts @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { ReferralTableProps } from './ReferralTable'; +import { ReferralTableView } from './ReferralTableView'; + +const sampleReferrals = [ + { + dateReferralStarted: '2024-01-15T00:00:00.000Z', + dateConverted: '2024-01-20T00:00:00.000Z', + referredUser: { firstName: 'Taylor', lastName: 'Smith' }, + rewards: [{ prettyValue: '$25.00', type: 'CASH', statuses: ['APPROVED'] }], + dateModerated: '2024-01-21T00:00:00.000Z', + }, + { + dateReferralStarted: '2024-02-01T00:00:00.000Z', + dateConverted: '', + referredUser: { firstName: 'Jordan', lastName: 'Lee' }, + rewards: [{ prettyValue: '$10.00', type: 'CASH', statuses: ['PENDING'] }], + dateModerated: '', + }, + { + dateReferralStarted: '2024-02-10T00:00:00.000Z', + dateConverted: '2024-02-14T00:00:00.000Z', + referredUser: { firstName: 'Alex', lastName: 'Chen' }, + rewards: [{ prettyValue: '$15.00', type: 'POINTS', statuses: ['APPROVED'] }], + dateModerated: '2024-02-15T00:00:00.000Z', + }, + { + dateReferralStarted: '2024-03-03T00:00:00.000Z', + dateConverted: '', + referredUser: { firstName: 'Morgan', lastName: 'Brown' }, + rewards: [], + dateModerated: '', + }, +]; + +const baseProps: ReferralTableProps = { + perPage: 4, + programId: '41863', + showLabels: true, + hiddenColumns: '', + dateShownColumn: 'Date Referred', + nameShownColumn: 'Name', + rewardsShownColumn: 'Rewards', + statusShownColumn: 'Status', + emptyStateText: 'No referrals yet', + emptyStateImage: '', +}; + +const renderTable = ( + args: Partial = {}, + overrides: Partial[0]> = {} +) => + ReferralTableView({ + ...baseProps, + ...args, + referrals: sampleReferrals, + loading: false, + totalCount: sampleReferrals.length, + totalPages: 1, + currentPage: 0, + nextPage: () => undefined, + prevPage: () => undefined, + empty: false, + ...overrides, + }); + +const meta: Meta = { + title: 'Components/ReferralTable', + component: 'sql-referral-table', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => renderTable(), +}; + +export const Loading: Story = { + render: () => renderTable({}, { loading: true }), +}; + +export const Empty: Story = { + render: () => renderTable({}, { referrals: [], totalCount: 0, totalPages: 0, empty: true }), +}; + +export const WithPagination: Story = { + render: () => renderTable({}, { totalCount: 8, totalPages: 2, currentPage: 0 }), +}; + +export const HiddenColumns: Story = { + render: () => renderTable({ hiddenColumns: 'rewards,status' }), +}; diff --git a/packages/lit-components/src/components/ReferralTable/ReferralTable.ts b/packages/lit-components/src/components/ReferralTable/ReferralTable.ts new file mode 100644 index 0000000000..9940368c5a --- /dev/null +++ b/packages/lit-components/src/components/ReferralTable/ReferralTable.ts @@ -0,0 +1,77 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ReferralTableView } from './ReferralTableView'; +import { useDemoReferralTable, useReferralTable } from './useReferralTable'; + +export interface ReferralTableProps { + perPage: number; + programId?: string; + showLabels: boolean; + hiddenColumns?: string; + dateShownColumn: string; + nameShownColumn: string; + rewardsShownColumn: string; + statusShownColumn: string; + emptyStateText: string; + emptyStateImage?: string; +} + +const parseBoolean = (value: unknown, fallback: boolean) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsedValue = Number(value); + return Number.isNaN(parsedValue) ? fallback : parsedValue; + } + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-referral-table': HTMLElement; + } +} + +export const ReferralTable = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: ReferralTableProps = { + perPage: parseNumber(rawProps.perPage, 4), + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + showLabels: parseBoolean(rawProps.showLabels, true), + hiddenColumns: typeof rawProps.hiddenColumns === 'string' ? rawProps.hiddenColumns : undefined, + dateShownColumn: + typeof rawProps.dateShownColumn === 'string' ? rawProps.dateShownColumn : 'Date Referred', + nameShownColumn: typeof rawProps.nameShownColumn === 'string' ? rawProps.nameShownColumn : 'Name', + rewardsShownColumn: + typeof rawProps.rewardsShownColumn === 'string' ? rawProps.rewardsShownColumn : 'Rewards', + statusShownColumn: + typeof rawProps.statusShownColumn === 'string' ? rawProps.statusShownColumn : 'Status', + emptyStateText: typeof rawProps.emptyStateText === 'string' ? rawProps.emptyStateText : 'No referrals yet', + emptyStateImage: typeof rawProps.emptyStateImage === 'string' ? rawProps.emptyStateImage : undefined, + }; + + const hookProps = isDemo() ? useDemoReferralTable(props) : useReferralTable(props); + + return ReferralTableView({ ...props, ...hookProps }); + }, + 'sql-referral-table', + [ + 'per-page', + 'program-id', + 'show-labels', + 'hidden-columns', + 'date-shown-column', + 'name-shown-column', + 'rewards-shown-column', + 'status-shown-column', + 'empty-state-text', + 'empty-state-image', + ] as const +); diff --git a/packages/lit-components/src/components/ReferralTable/ReferralTableView.ts b/packages/lit-components/src/components/ReferralTable/ReferralTableView.ts new file mode 100644 index 0000000000..c4efd3fe1d --- /dev/null +++ b/packages/lit-components/src/components/ReferralTable/ReferralTableView.ts @@ -0,0 +1,157 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { ReferralTableProps } from './ReferralTable'; +import { useReferralTable } from './useReferralTable'; + +type ReferralRow = ReturnType['referrals'][number]; + +const baseStyles = ` + :host { display: block; } + .table-container { width: 100%; overflow-x: auto; } + table { width: 100%; border-collapse: collapse; } + th { text-align: left; padding: var(--sl-spacing-small) var(--sl-spacing-medium); font-weight: var(--sl-font-weight-semibold); font-size: var(--sl-font-size-small); color: var(--sl-color-neutral-600); border-bottom: 2px solid var(--sl-color-neutral-200); } + td { padding: var(--sl-spacing-small) var(--sl-spacing-medium); border-bottom: 1px solid var(--sl-color-neutral-100); font-size: var(--sl-font-size-small); } + tr:hover { background: var(--sl-color-neutral-50); } + .pagination { display: flex; justify-content: center; align-items: center; gap: var(--sl-spacing-small); padding: var(--sl-spacing-medium) 0; } + .empty-state { text-align: center; padding: var(--sl-spacing-x-large); color: var(--sl-color-neutral-500); } + .empty-state-image { max-width: 180px; margin-bottom: var(--sl-spacing-medium); } + .status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: var(--sl-font-size-x-small); font-weight: var(--sl-font-weight-semibold); } + .status-converted { background: var(--sl-color-success-100); color: var(--sl-color-success-700); } + .status-pending { background: var(--sl-color-warning-100); color: var(--sl-color-warning-700); } + .page-info { font-size: var(--sl-font-size-small); color: var(--sl-color-neutral-600); } +`; + +function formatDate(date?: string) { + if (!date) return '—'; + return new Date(date).toLocaleDateString(); +} + +function getName(referral: { + referredUser?: { firstName?: string; lastName?: string }; +}) { + const firstName = referral.referredUser?.firstName || ''; + const lastName = referral.referredUser?.lastName || ''; + const fullName = `${firstName} ${lastName}`.trim(); + return fullName || '—'; +} + +function getRewards(referral: { + rewards?: Array<{ prettyValue?: string }>; +}) { + const rewardValues = (referral.rewards || []).map((reward) => reward.prettyValue).filter(Boolean); + return rewardValues.length > 0 ? rewardValues.join(', ') : '—'; +} + +function getStatus(referral: { + dateConverted?: string; +}) { + return referral.dateConverted ? 'Converted' : 'Pending'; +} + +function getHiddenColumns(hiddenColumns?: string) { + return new Set( + (hiddenColumns || '') + .split(',') + .map((column) => column.trim().toLowerCase()) + .filter(Boolean) + ); +} + +export function ReferralTableView(props: ReferralTableProps & ReturnType) { + const hiddenColumns = getHiddenColumns(props.hiddenColumns); + const columns = [ + { key: 'date', label: props.dateShownColumn || 'Date Referred' }, + { key: 'name', label: props.nameShownColumn || 'Name' }, + { key: 'rewards', label: props.rewardsShownColumn || 'Rewards' }, + { key: 'status', label: props.statusShownColumn || 'Status' }, + ].filter((column) => !hiddenColumns.has(column.key)); + + if (props.loading) { + return html` + +
Loading referrals...
+ `; + } + + if (props.empty) { + return html` + +
+ ${props.emptyStateImage + ? html`` + : ''} +
${props.emptyStateText || 'No referrals yet'}
+
+ `; + } + + return html` + +
+ + ${props.showLabels + ? html` + + + ${columns.map((column) => html``) } + + + ` + : ''} + + ${props.referrals.map( + (referral: ReferralRow) => html` + + ${!hiddenColumns.has('date') + ? html`` + : ''} + ${!hiddenColumns.has('name') ? html`` : ''} + ${!hiddenColumns.has('rewards') ? html`` : ''} + ${!hiddenColumns.has('status') + ? html` + + ` + : ''} + + ` + )} + +
${column.label}
${formatDate(referral.dateReferralStarted)}${getName(referral)}${getRewards(referral)} + + ${getStatus(referral)} + +
+
+ ${props.totalPages > 1 + ? html` + + ` + : ''} + `; +} diff --git a/packages/lit-components/src/components/ReferralTable/index.ts b/packages/lit-components/src/components/ReferralTable/index.ts new file mode 100644 index 0000000000..0dce77ff74 --- /dev/null +++ b/packages/lit-components/src/components/ReferralTable/index.ts @@ -0,0 +1 @@ +export * from './ReferralTable'; diff --git a/packages/lit-components/src/components/ReferralTable/useReferralTable.test.ts b/packages/lit-components/src/components/ReferralTable/useReferralTable.test.ts new file mode 100644 index 0000000000..d7fde7e40c --- /dev/null +++ b/packages/lit-components/src/components/ReferralTable/useReferralTable.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { + user: { + referrals: { + totalCount: 9, + data: [{ referredUser: { firstName: 'Jane', lastName: 'Doe' } }], + }, + }, + }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => ({ + setter: vi.fn(), + stateValue: 0, + useState: vi.fn((initial: unknown) => [universalHooksMock.stateValue ?? initial, universalHooksMock.setter] as const), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useReferralTable } from './useReferralTable'; + +describe('useReferralTable', () => { + beforeEach(() => { + universalHooksMock.stateValue = 0; + universalHooksMock.setter.mockReset(); + componentBoilerplateMock.useQuery.mockClear(); + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + user: { + referrals: { + totalCount: 9, + data: [{ referredUser: { firstName: 'Jane', lastName: 'Doe' } }], + }, + }, + }, + loading: false, + }); + }); + + it('returns referrals from query data', () => { + const result = useReferralTable({} as any); + + expect(result.referrals).toHaveLength(1); + expect(result.referrals[0]?.referredUser?.firstName).toBe('Jane'); + }); + + it('calculates total pages correctly', () => { + const result = useReferralTable({ perPage: '4' } as any); + + expect(result.totalCount).toBe(9); + expect(result.totalPages).toBe(3); + }); + + it('advances and rewinds pages', () => { + const firstPage = useReferralTable({ perPage: '4' } as any); + firstPage.nextPage(); + expect(universalHooksMock.setter).toHaveBeenCalledWith(1); + + universalHooksMock.setter.mockReset(); + universalHooksMock.stateValue = 1; + const secondPage = useReferralTable({ perPage: '4' } as any); + secondPage.prevPage(); + expect(universalHooksMock.setter).toHaveBeenCalledWith(0); + }); + + it('returns empty when there are no referrals and not loading', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { referrals: { totalCount: 0, data: [] } } }, + loading: false, + }); + + const result = useReferralTable({} as any); + + expect(result.empty).toBe(true); + }); + + it('uses 4 as the default per-page value', () => { + useReferralTable({} as any); + + expect(componentBoilerplateMock.useQuery).toHaveBeenCalledWith(expect.anything(), { + programId: 'test-program', + offset: 0, + limit: 4, + }, false); + }); +}); diff --git a/packages/lit-components/src/components/ReferralTable/useReferralTable.ts b/packages/lit-components/src/components/ReferralTable/useReferralTable.ts new file mode 100644 index 0000000000..21a0b82958 --- /dev/null +++ b/packages/lit-components/src/components/ReferralTable/useReferralTable.ts @@ -0,0 +1,117 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { ReferralTableProps } from './ReferralTable'; + +const REFERRAL_TABLE_QUERY = gql` + query getReferrals($programId: ID, $offset: Int, $limit: Int) { + user: viewer { + ... on User { + referrals(filter: { programId_eq: $programId }, limit: $limit, offset: $offset) { + totalCount + count + data { + dateReferralStarted + dateConverted + referredUser { + firstName + lastName + } + rewards { + prettyValue + type + statuses + } + dateModerated + } + } + } + } + } +`; + +export function useReferralTable(props: ReferralTableProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const perPage = Number(props.perPage) || 4; + const [currentPage, setCurrentPage] = useState(0); + + const { data, loading } = useQuery( + REFERRAL_TABLE_QUERY, + { programId, offset: currentPage * perPage, limit: perPage }, + !user?.jwt + ); + + const referrals = data?.user?.referrals?.data || []; + const totalCount = data?.user?.referrals?.totalCount || 0; + const totalPages = Math.ceil(totalCount / perPage); + + function nextPage() { + if (currentPage < totalPages - 1) setCurrentPage(currentPage + 1); + } + + function prevPage() { + if (currentPage > 0) setCurrentPage(currentPage - 1); + } + + return { + referrals, + loading, + totalCount, + totalPages, + currentPage, + nextPage, + prevPage, + empty: !loading && referrals.length === 0, + }; +} + +export function useDemoReferralTable(props: ReferralTableProps): ReturnType { + const perPage = Number(props.perPage) || 4; + const [currentPage, setCurrentPage] = useState(0); + const mockReferrals = [ + { + dateReferralStarted: '2024-01-15', + dateConverted: '2024-01-20', + referredUser: { firstName: 'Jane', lastName: 'Doe' }, + rewards: [{ prettyValue: '$10.00', type: 'CREDIT', statuses: ['AVAILABLE'] }], + dateModerated: null, + }, + { + dateReferralStarted: '2024-02-10', + dateConverted: null, + referredUser: { firstName: 'Bob', lastName: 'Smith' }, + rewards: [], + dateModerated: null, + }, + { + dateReferralStarted: '2024-03-05', + dateConverted: '2024-03-10', + referredUser: { firstName: 'Alice', lastName: 'Jones' }, + rewards: [{ prettyValue: '$5.00', type: 'CREDIT', statuses: ['AVAILABLE'] }], + dateModerated: null, + }, + ]; + const referrals = mockReferrals.slice(currentPage * perPage, (currentPage + 1) * perPage); + const totalCount = mockReferrals.length; + const totalPages = Math.ceil(totalCount / perPage); + + function nextPage() { + if (currentPage < totalPages - 1) setCurrentPage(currentPage + 1); + } + + function prevPage() { + if (currentPage > 0) setCurrentPage(currentPage - 1); + } + + return { + referrals, + loading: false, + totalCount, + totalPages, + currentPage, + nextPage, + prevPage, + empty: referrals.length === 0, + }; +} diff --git a/packages/lit-components/src/components/ReferredRegistration/ReferredRegistration.stories.ts b/packages/lit-components/src/components/ReferredRegistration/ReferredRegistration.stories.ts new file mode 100644 index 0000000000..97e3125ed8 --- /dev/null +++ b/packages/lit-components/src/components/ReferredRegistration/ReferredRegistration.stories.ts @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { UI } from '../../ui'; +import '../../index'; + +const meta: Meta = { + title: 'Components/ReferredRegistration', + component: 'sql-referred-registration', + tags: ['autodocs'], + argTypes: { + 'header-text': { control: 'text' }, + 'description-text': { control: 'text' }, + 'submit-label': { control: 'text' }, + }, + render: (args) => html` + + ${UI.Input({ label: 'First Name' })} + ${UI.Input({ label: 'Last Name' })} + ${UI.Input({ label: 'Email', type: 'email' })} + ${UI.Button({ variant: 'primary', children: args['submit-label'] || 'Register' })} + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomText: Story = { + args: { + 'header-text': 'Join Through Your Referral', + 'description-text': 'Finish signing up below to receive your referral reward.', + 'submit-label': 'Create Account', + }, +}; diff --git a/packages/lit-components/src/components/ReferredRegistration/ReferredRegistration.ts b/packages/lit-components/src/components/ReferredRegistration/ReferredRegistration.ts new file mode 100644 index 0000000000..de7219ae08 --- /dev/null +++ b/packages/lit-components/src/components/ReferredRegistration/ReferredRegistration.ts @@ -0,0 +1,31 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ReferredRegistrationView } from './ReferredRegistrationView'; + +export interface ReferredRegistrationProps { + headerText: string; + descriptionText: string; + submitLabel: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-referred-registration': HTMLElement; + } +} + +export const ReferredRegistration = useComponent( + (host) => { + const rawProps = getProps(host); + const props: ReferredRegistrationProps = { + headerText: rawProps.headerText || "You've Been Referred!", + descriptionText: + rawProps.descriptionText || 'Complete your registration to claim your reward.', + submitLabel: rawProps.submitLabel || 'Register', + }; + + return ReferredRegistrationView(props); + }, + 'sql-referred-registration', + ['header-text', 'description-text', 'submit-label'] as const +); diff --git a/packages/lit-components/src/components/ReferredRegistration/ReferredRegistrationView.ts b/packages/lit-components/src/components/ReferredRegistration/ReferredRegistrationView.ts new file mode 100644 index 0000000000..cb44ced4dc --- /dev/null +++ b/packages/lit-components/src/components/ReferredRegistration/ReferredRegistrationView.ts @@ -0,0 +1,44 @@ +import { html } from 'lit'; +import type { ReferredRegistrationProps } from './ReferredRegistration'; + +export function ReferredRegistrationView(props: ReferredRegistrationProps) { + return html` + +
+
+

${props.headerText}

+ ${props.descriptionText + ? html`

${props.descriptionText}

` + : ''} +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/ReferredRegistration/index.ts b/packages/lit-components/src/components/ReferredRegistration/index.ts new file mode 100644 index 0000000000..d60fa268d4 --- /dev/null +++ b/packages/lit-components/src/components/ReferredRegistration/index.ts @@ -0,0 +1 @@ +export * from './ReferredRegistration'; diff --git a/packages/lit-components/src/components/RewardExchangeList/RewardExchangeList.stories.ts b/packages/lit-components/src/components/RewardExchangeList/RewardExchangeList.stories.ts new file mode 100644 index 0000000000..fd8a58b382 --- /dev/null +++ b/packages/lit-components/src/components/RewardExchangeList/RewardExchangeList.stories.ts @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { RewardExchangeListView } from './RewardExchangeListView'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = '******'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: '******', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/RewardExchangeList', + component: 'sql-reward-exchange-list', + tags: ['autodocs'], + argTypes: { + 'header-text': { control: 'text' }, + 'empty-text': { control: 'text' }, + 'program-id': { control: 'text' }, + }, + render: (args) => { + setupGlobals(); + return html``; + }, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Redeem Rewards', + emptyText: 'No rewards available to redeem', +}; + +export const Default: Story = {}; + +export const Loading: Story = { + render: () => + RewardExchangeListView({ + ...baseProps, + exchanges: [], + currentBalance: '0', + loading: true, + empty: false, + }), +}; + +export const Empty: Story = { + render: () => + RewardExchangeListView({ + ...baseProps, + exchanges: [], + currentBalance: '0', + loading: false, + empty: true, + }), +}; + +export const WithExchanges: Story = { + render: () => + RewardExchangeListView({ + ...baseProps, + currentBalance: '$75', + loading: false, + empty: false, + exchanges: [ + { + id: '1', + name: 'Coffee Gift Card', + description: 'Enjoy your favorite drink on us.', + imageUrl: + 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=200&q=80', + costPrettyValue: '$10', + costValue: 10, + available: true, + }, + { + id: '2', + name: 'Swag Pack', + description: 'T-shirt, stickers, and a notebook.', + imageUrl: + 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80', + costPrettyValue: '$25', + costValue: 25, + available: false, + }, + ], + }), +}; diff --git a/packages/lit-components/src/components/RewardExchangeList/RewardExchangeList.ts b/packages/lit-components/src/components/RewardExchangeList/RewardExchangeList.ts new file mode 100644 index 0000000000..37e06cacd7 --- /dev/null +++ b/packages/lit-components/src/components/RewardExchangeList/RewardExchangeList.ts @@ -0,0 +1,43 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { RewardExchangeListView } from './RewardExchangeListView'; +import { useDemoRewardExchangeList, useRewardExchangeList } from './useRewardExchangeList'; + +export interface RewardExchangeItem { + id: string; + name: string; + description?: string; + imageUrl?: string; + costPrettyValue: string; + costValue?: number; + available?: boolean; +} + +export interface RewardExchangeListProps { + headerText: string; + emptyText: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-reward-exchange-list': HTMLElement; + } +} + +export const RewardExchangeList = useComponent( + (host) => { + const props: RewardExchangeListProps = { + headerText: 'Redeem Rewards', + emptyText: 'No rewards available to redeem', + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoRewardExchangeList(props) : useRewardExchangeList(props); + + return RewardExchangeListView({ ...props, ...hookProps }); + }, + 'sql-reward-exchange-list', + ['header-text', 'empty-text', 'program-id'] as const +); diff --git a/packages/lit-components/src/components/RewardExchangeList/RewardExchangeListView.ts b/packages/lit-components/src/components/RewardExchangeList/RewardExchangeListView.ts new file mode 100644 index 0000000000..e50fca964d --- /dev/null +++ b/packages/lit-components/src/components/RewardExchangeList/RewardExchangeListView.ts @@ -0,0 +1,111 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { RewardExchangeItem, RewardExchangeListProps } from './RewardExchangeList'; +import { useRewardExchangeList } from './useRewardExchangeList'; + +export function RewardExchangeListView( + props: RewardExchangeListProps & ReturnType +) { + return html` + +
+
+

${props.headerText}

+ Balance: ${props.currentBalance} +
+ ${props.loading + ? html`${UI.Spinner({})}` + : props.empty + ? html`
${props.emptyText}
` + : html`
+ ${props.exchanges.map( + (item: RewardExchangeItem) => html` +
+ ${item.imageUrl + ? html`${item.name}` + : ''} +
+
${item.name}
+ ${item.description + ? html`
${item.description}
` + : ''} +
+
${item.costPrettyValue}
+ ${UI.Button({ size: 'small', variant: 'primary', disabled: !item.available, children: 'Redeem' })} +
+ ` + )} +
`} +
+ `; +} diff --git a/packages/lit-components/src/components/RewardExchangeList/index.ts b/packages/lit-components/src/components/RewardExchangeList/index.ts new file mode 100644 index 0000000000..6e65b22aad --- /dev/null +++ b/packages/lit-components/src/components/RewardExchangeList/index.ts @@ -0,0 +1 @@ +export * from './RewardExchangeList'; diff --git a/packages/lit-components/src/components/RewardExchangeList/useRewardExchangeList.test.ts b/packages/lit-components/src/components/RewardExchangeList/useRewardExchangeList.test.ts new file mode 100644 index 0000000000..4172cbd301 --- /dev/null +++ b/packages/lit-components/src/components/RewardExchangeList/useRewardExchangeList.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { + user: { + rewardBalanceDetails: [ + { + prettyValue: '$125.00', + exchangeList: [{ id: 'reward-1', name: 'Gift Card', costPrettyValue: '$25.00', available: true }], + }, + ], + }, + }, + loading: false, + })), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { useRewardExchangeList } from './useRewardExchangeList'; + +describe('useRewardExchangeList', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + user: { + rewardBalanceDetails: [ + { + prettyValue: '$125.00', + exchangeList: [{ id: 'reward-1', name: 'Gift Card', costPrettyValue: '$25.00', available: true }], + }, + ], + }, + }, + loading: false, + }); + }); + + it('returns exchanges from the first balance detail', () => { + const result = useRewardExchangeList({} as any); + + expect(result.exchanges).toHaveLength(1); + expect(result.exchanges[0]?.name).toBe('Gift Card'); + }); + + it('returns the current balance from the first balance detail', () => { + const result = useRewardExchangeList({} as any); + + expect(result.currentBalance).toBe('$125.00'); + }); + + it('returns empty when no exchanges are available', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { rewardBalanceDetails: [{ prettyValue: '$0.00', exchangeList: [] }] } }, + loading: false, + }); + + const result = useRewardExchangeList({} as any); + + expect(result.empty).toBe(true); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useRewardExchangeList({} as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/RewardExchangeList/useRewardExchangeList.ts b/packages/lit-components/src/components/RewardExchangeList/useRewardExchangeList.ts new file mode 100644 index 0000000000..695eecd281 --- /dev/null +++ b/packages/lit-components/src/components/RewardExchangeList/useRewardExchangeList.ts @@ -0,0 +1,74 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { gql } from 'graphql-request'; +import { RewardExchangeListProps } from './RewardExchangeList'; + +const REWARDS_QUERY = gql` + query getRewardExchanges($programId: ID) { + user: viewer { + ... on User { + rewardBalanceDetails(programId: $programId) { + type + unit + value + prettyValue + exchangeList { + id + name + description + imageUrl + costPrettyValue + costValue + available + } + } + } + } + } +`; + +export function useRewardExchangeList(props: RewardExchangeListProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const { data, loading } = useQuery(REWARDS_QUERY, { programId }, !user?.jwt); + + const balanceDetails = data?.user?.rewardBalanceDetails || []; + const balance = balanceDetails[0]; + const exchanges = balance?.exchangeList || []; + const currentBalance = balance?.prettyValue || '0'; + + return { exchanges, currentBalance, loading, empty: !loading && exchanges.length === 0 }; +} + +export function useDemoRewardExchangeList(_props: RewardExchangeListProps): ReturnType { + const exchanges = [ + { + id: '1', + name: '$5 Gift Card', + description: 'Redeem for a $5 gift card', + imageUrl: '', + costPrettyValue: '$5.00', + costValue: 500, + available: true, + }, + { + id: '2', + name: '$10 Gift Card', + description: 'Redeem for a $10 gift card', + imageUrl: '', + costPrettyValue: '$10.00', + costValue: 1000, + available: true, + }, + { + id: '3', + name: '$25 Gift Card', + description: 'Redeem for a $25 gift card', + imageUrl: '', + costPrettyValue: '$25.00', + costValue: 2500, + available: false, + }, + ]; + + return { exchanges, currentBalance: '$50.00', loading: false, empty: false }; +} diff --git a/packages/lit-components/src/components/RewardsTable/RewardsTable.stories.ts b/packages/lit-components/src/components/RewardsTable/RewardsTable.stories.ts new file mode 100644 index 0000000000..57e916830b --- /dev/null +++ b/packages/lit-components/src/components/RewardsTable/RewardsTable.stories.ts @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { RewardsTableProps } from './RewardsTable'; +import { RewardsTableView } from './RewardsTableView'; + +const sampleRewards = [ + { + id: 'reward-1', + type: 'CASH', + value: 25, + unit: 'USD', + prettyValue: '$25.00', + dateGiven: '2024-01-15T00:00:00.000Z', + dateExpires: '2024-12-31T00:00:00.000Z', + statuses: ['APPROVED'], + fuelTankCode: '', + programId: '41863', + sourceUser: { firstName: 'Taylor', lastName: 'Smith' }, + }, + { + id: 'reward-2', + type: 'POINTS', + value: 100, + unit: 'points', + prettyValue: '100 pts', + dateGiven: '2024-02-01T00:00:00.000Z', + dateExpires: '', + statuses: ['PENDING'], + fuelTankCode: '', + programId: '41863', + sourceUser: { firstName: 'Jordan', lastName: 'Lee' }, + }, + { + id: 'reward-3', + type: 'CASH', + value: 10, + unit: 'USD', + prettyValue: '$10.00', + dateGiven: '2024-02-10T00:00:00.000Z', + dateExpires: '', + statuses: ['AVAILABLE'], + fuelTankCode: '', + programId: '41863', + sourceUser: { firstName: 'Alex', lastName: 'Chen' }, + }, + { + id: 'reward-4', + type: 'GIFT_CARD', + value: 50, + unit: 'USD', + prettyValue: '$50 Gift Card', + dateGiven: '2024-03-01T00:00:00.000Z', + dateExpires: '', + statuses: ['PAID'], + fuelTankCode: '', + programId: '41863', + sourceUser: { firstName: 'Morgan', lastName: 'Brown' }, + }, +]; + +const baseProps: RewardsTableProps = { + perPage: 4, + programId: '41863', + showLabels: true, + dateShownColumn: 'Date Received', + rewardShownColumn: 'Reward', + sourceShownColumn: 'Source', + statusShownColumn: 'Status', + emptyStateText: 'No rewards yet', +}; + +const renderTable = ( + args: Partial = {}, + overrides: Partial[0]> = {} +) => + RewardsTableView({ + ...baseProps, + ...args, + rewards: sampleRewards, + loading: false, + totalCount: sampleRewards.length, + totalPages: 1, + currentPage: 0, + nextPage: () => undefined, + prevPage: () => undefined, + empty: false, + ...overrides, + }); + +const meta: Meta = { + title: 'Components/RewardsTable', + component: 'sql-rewards-table', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => renderTable(), +}; + +export const Loading: Story = { + render: () => renderTable({}, { loading: true }), +}; + +export const Empty: Story = { + render: () => renderTable({}, { rewards: [], totalCount: 0, totalPages: 0, empty: true }), +}; + +export const WithPagination: Story = { + render: () => renderTable({}, { totalCount: 8, totalPages: 2, currentPage: 0 }), +}; diff --git a/packages/lit-components/src/components/RewardsTable/RewardsTable.ts b/packages/lit-components/src/components/RewardsTable/RewardsTable.ts new file mode 100644 index 0000000000..8d84241d8c --- /dev/null +++ b/packages/lit-components/src/components/RewardsTable/RewardsTable.ts @@ -0,0 +1,69 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { RewardsTableView } from './RewardsTableView'; +import { useDemoRewardsTable, useRewardsTable } from './useRewardsTable'; + +export interface RewardsTableProps { + perPage: number; + programId?: string; + showLabels: boolean; + dateShownColumn: string; + rewardShownColumn: string; + sourceShownColumn: string; + statusShownColumn: string; + emptyStateText: string; +} + +const parseBoolean = (value: unknown, fallback: boolean) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsedValue = Number(value); + return Number.isNaN(parsedValue) ? fallback : parsedValue; + } + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-rewards-table': HTMLElement; + } +} + +export const RewardsTable = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: RewardsTableProps = { + perPage: parseNumber(rawProps.perPage, 4), + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + showLabels: parseBoolean(rawProps.showLabels, true), + dateShownColumn: + typeof rawProps.dateShownColumn === 'string' ? rawProps.dateShownColumn : 'Date Received', + rewardShownColumn: typeof rawProps.rewardShownColumn === 'string' ? rawProps.rewardShownColumn : 'Reward', + sourceShownColumn: typeof rawProps.sourceShownColumn === 'string' ? rawProps.sourceShownColumn : 'Source', + statusShownColumn: typeof rawProps.statusShownColumn === 'string' ? rawProps.statusShownColumn : 'Status', + emptyStateText: typeof rawProps.emptyStateText === 'string' ? rawProps.emptyStateText : 'No rewards yet', + }; + + const hookProps = isDemo() ? useDemoRewardsTable(props) : useRewardsTable(props); + + return RewardsTableView({ ...props, ...hookProps }); + }, + 'sql-rewards-table', + [ + 'per-page', + 'program-id', + 'show-labels', + 'date-shown-column', + 'reward-shown-column', + 'source-shown-column', + 'status-shown-column', + 'empty-state-text', + ] as const +); diff --git a/packages/lit-components/src/components/RewardsTable/RewardsTableView.ts b/packages/lit-components/src/components/RewardsTable/RewardsTableView.ts new file mode 100644 index 0000000000..3bda8696b0 --- /dev/null +++ b/packages/lit-components/src/components/RewardsTable/RewardsTableView.ts @@ -0,0 +1,141 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { RewardsTableProps } from './RewardsTable'; +import { useRewardsTable } from './useRewardsTable'; + +type RewardRow = ReturnType['rewards'][number]; + +const baseStyles = ` + :host { display: block; } + .table-container { width: 100%; overflow-x: auto; } + table { width: 100%; border-collapse: collapse; } + th { text-align: left; padding: var(--sl-spacing-small) var(--sl-spacing-medium); font-weight: var(--sl-font-weight-semibold); font-size: var(--sl-font-size-small); color: var(--sl-color-neutral-600); border-bottom: 2px solid var(--sl-color-neutral-200); } + td { padding: var(--sl-spacing-small) var(--sl-spacing-medium); border-bottom: 1px solid var(--sl-color-neutral-100); font-size: var(--sl-font-size-small); } + tr:hover { background: var(--sl-color-neutral-50); } + .pagination { display: flex; justify-content: center; align-items: center; gap: var(--sl-spacing-small); padding: var(--sl-spacing-medium) 0; } + .empty-state { text-align: center; padding: var(--sl-spacing-x-large); color: var(--sl-color-neutral-500); } + .status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: var(--sl-font-size-x-small); font-weight: var(--sl-font-weight-semibold); } + .status-converted { background: var(--sl-color-success-100); color: var(--sl-color-success-700); } + .status-pending { background: var(--sl-color-warning-100); color: var(--sl-color-warning-700); } + .page-info { font-size: var(--sl-font-size-small); color: var(--sl-color-neutral-600); } +`; + +function formatDate(date?: string) { + if (!date) return '—'; + return new Date(date).toLocaleDateString(); +} + +function getRewardValue(reward: { + prettyValue?: string; + value?: string | number; + unit?: string; +}) { + if (reward.prettyValue) return reward.prettyValue; + if (reward.value !== undefined && reward.value !== null) { + return reward.unit ? `${reward.value} ${reward.unit}` : String(reward.value); + } + return '—'; +} + +function getSource(reward: { + sourceUser?: { firstName?: string; lastName?: string }; +}) { + const firstName = reward.sourceUser?.firstName || ''; + const lastName = reward.sourceUser?.lastName || ''; + const fullName = `${firstName} ${lastName}`.trim(); + return fullName || '—'; +} + +function getStatusText(reward: { statuses?: string[] }) { + const status = reward.statuses?.[0]; + if (!status) return 'Pending'; + return status + .toLowerCase() + .split('_') + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' '); +} + +function getStatusClass(reward: { statuses?: string[] }) { + const status = reward.statuses?.[0]?.toUpperCase() || ''; + return ['APPROVED', 'AVAILABLE', 'PAID', 'FULFILLED', 'COMPLETE'].includes(status) + ? 'status-converted' + : 'status-pending'; +} + +export function RewardsTableView(props: RewardsTableProps & ReturnType) { + if (props.loading) { + return html` + +
Loading rewards...
+ `; + } + + if (props.empty) { + return html` + +
${props.emptyStateText || 'No rewards yet'}
+ `; + } + + return html` + +
+ + ${props.showLabels + ? html` + + + + + + + + + ` + : ''} + + ${props.rewards.map( + (reward: RewardRow) => html` + + + + + + + ` + )} + +
${props.dateShownColumn || 'Date Received'}${props.rewardShownColumn || 'Reward'}${props.sourceShownColumn || 'Source'}${props.statusShownColumn || 'Status'}
${formatDate(reward.dateGiven)}${getRewardValue(reward)}${getSource(reward)} + ${getStatusText(reward)} +
+
+ ${props.totalPages > 1 + ? html` + + ` + : ''} + `; +} diff --git a/packages/lit-components/src/components/RewardsTable/index.ts b/packages/lit-components/src/components/RewardsTable/index.ts new file mode 100644 index 0000000000..20b2c5f351 --- /dev/null +++ b/packages/lit-components/src/components/RewardsTable/index.ts @@ -0,0 +1 @@ +export * from './RewardsTable'; diff --git a/packages/lit-components/src/components/RewardsTable/useRewardsTable.test.ts b/packages/lit-components/src/components/RewardsTable/useRewardsTable.test.ts new file mode 100644 index 0000000000..5956139168 --- /dev/null +++ b/packages/lit-components/src/components/RewardsTable/useRewardsTable.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { + user: { + rewards: { + totalCount: 9, + data: [{ id: 'reward-1', prettyValue: '$10.00', type: 'CREDIT' }], + }, + }, + }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useRewardsTable } from './useRewardsTable'; + +describe('useRewardsTable', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + user: { + rewards: { + totalCount: 9, + data: [{ id: 'reward-1', prettyValue: '$10.00', type: 'CREDIT' }], + }, + }, + }, + loading: false, + }); + }); + + it('returns rewards from query data', () => { + const result = useRewardsTable({} as any); + + expect(result.rewards).toHaveLength(1); + expect(result.rewards[0]?.id).toBe('reward-1'); + }); + + it('calculates total pages correctly', () => { + const result = useRewardsTable({ perPage: '4' } as any); + + expect(result.totalPages).toBe(3); + }); + + it('uses 4 as the default per-page value', () => { + useRewardsTable({} as any); + + expect(componentBoilerplateMock.useQuery).toHaveBeenCalledWith(expect.anything(), { + programId: 'test-program', + offset: 0, + limit: 4, + }, false); + }); + + it('returns empty when there are no rewards and not loading', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { rewards: { totalCount: 0, data: [] } } }, + loading: false, + }); + + const result = useRewardsTable({} as any); + + expect(result.empty).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/RewardsTable/useRewardsTable.ts b/packages/lit-components/src/components/RewardsTable/useRewardsTable.ts new file mode 100644 index 0000000000..db2f329d13 --- /dev/null +++ b/packages/lit-components/src/components/RewardsTable/useRewardsTable.ts @@ -0,0 +1,137 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { RewardsTableProps } from './RewardsTable'; + +const REWARDS_TABLE_QUERY = gql` + query getRewards($programId: ID, $offset: Int, $limit: Int) { + user: viewer { + ... on User { + rewards(filter: { programId_eq: $programId }, limit: $limit, offset: $offset) { + totalCount + count + data { + id + type + value + unit + prettyValue + dateGiven + dateExpires + statuses + fuelTankCode + programId + sourceUser { + firstName + lastName + } + } + } + } + } + } +`; + +export function useRewardsTable(props: RewardsTableProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const perPage = Number(props.perPage) || 4; + const [currentPage, setCurrentPage] = useState(0); + + const { data, loading } = useQuery( + REWARDS_TABLE_QUERY, + { programId, offset: currentPage * perPage, limit: perPage }, + !user?.jwt + ); + + const rewards = data?.user?.rewards?.data || []; + const totalCount = data?.user?.rewards?.totalCount || 0; + const totalPages = Math.ceil(totalCount / perPage); + + function nextPage() { + if (currentPage < totalPages - 1) setCurrentPage(currentPage + 1); + } + + function prevPage() { + if (currentPage > 0) setCurrentPage(currentPage - 1); + } + + return { + rewards, + loading, + totalCount, + totalPages, + currentPage, + nextPage, + prevPage, + empty: !loading && rewards.length === 0, + }; +} + +export function useDemoRewardsTable(props: RewardsTableProps): ReturnType { + const perPage = Number(props.perPage) || 4; + const [currentPage, setCurrentPage] = useState(0); + const mockRewards = [ + { + id: '1', + type: 'CREDIT', + value: 1000, + unit: 'POINT', + prettyValue: '$10.00', + dateGiven: '2024-01-15', + dateExpires: null, + statuses: ['AVAILABLE'], + fuelTankCode: null, + programId: 'program1', + sourceUser: { firstName: 'Jane', lastName: 'Doe' }, + }, + { + id: '2', + type: 'CREDIT', + value: 500, + unit: 'POINT', + prettyValue: '$5.00', + dateGiven: '2024-02-10', + dateExpires: '2025-02-10', + statuses: ['AVAILABLE'], + fuelTankCode: null, + programId: 'program1', + sourceUser: { firstName: 'Bob', lastName: 'Smith' }, + }, + { + id: '3', + type: 'FUEL_TANK', + value: 2500, + unit: 'CENT', + prettyValue: '$25.00', + dateGiven: '2024-03-05', + dateExpires: null, + statuses: ['AVAILABLE'], + fuelTankCode: 'CODE123', + programId: 'program1', + sourceUser: null, + }, + ]; + const rewards = mockRewards.slice(currentPage * perPage, (currentPage + 1) * perPage); + const totalCount = mockRewards.length; + const totalPages = Math.ceil(totalCount / perPage); + + function nextPage() { + if (currentPage < totalPages - 1) setCurrentPage(currentPage + 1); + } + + function prevPage() { + if (currentPage > 0) setCurrentPage(currentPage - 1); + } + + return { + rewards, + loading: false, + totalCount, + totalPages, + currentPage, + nextPage, + prevPage, + empty: rewards.length === 0, + }; +} diff --git a/packages/lit-components/src/components/Route/Route.stories.ts b/packages/lit-components/src/components/Route/Route.stories.ts new file mode 100644 index 0000000000..8025089cad --- /dev/null +++ b/packages/lit-components/src/components/Route/Route.stories.ts @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/Route', + component: 'sql-route', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + window.location.hash = '#/overview/details'; + + return html` + +

Visible when the path starts with /overview

+
+ `; + }, +}; + +export const ExactMatch: Story = { + render: () => { + window.location.hash = '#/overview'; + + return html` + +

Visible only when the path exactly matches /overview

+
+ `; + }, +}; diff --git a/packages/lit-components/src/components/Route/Route.ts b/packages/lit-components/src/components/Route/Route.ts new file mode 100644 index 0000000000..64a66a799d --- /dev/null +++ b/packages/lit-components/src/components/Route/Route.ts @@ -0,0 +1,33 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { RouteView } from './RouteView'; +import { useRoute } from './useRoute'; + +export interface RouteProps { + path: string; + exact?: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-route': HTMLElement; + } +} + +export const Route = useComponent( + (host) => { + const rawProps = getProps(host); + const props: RouteProps = { + ...rawProps, + path: rawProps.path || '', + exact: host.hasAttribute('exact') && host.getAttribute('exact') !== 'false', + }; + + const hookProps = useRoute(props); + host.toggleAttribute('hidden', !hookProps.visible); + + return RouteView({ ...props, ...hookProps }); + }, + 'sql-route', + ['path', 'exact'] as const +); diff --git a/packages/lit-components/src/components/Route/RouteView.ts b/packages/lit-components/src/components/Route/RouteView.ts new file mode 100644 index 0000000000..a8c1d11924 --- /dev/null +++ b/packages/lit-components/src/components/Route/RouteView.ts @@ -0,0 +1,18 @@ +import { html } from 'lit'; +import type { RouteProps } from './Route'; +import { useRoute } from './useRoute'; + +export function RouteView(_props: RouteProps & ReturnType) { + return html` + + + `; +} diff --git a/packages/lit-components/src/components/Route/index.ts b/packages/lit-components/src/components/Route/index.ts new file mode 100644 index 0000000000..1985efdddd --- /dev/null +++ b/packages/lit-components/src/components/Route/index.ts @@ -0,0 +1 @@ +export * from './Route'; diff --git a/packages/lit-components/src/components/Route/useRoute.test.ts b/packages/lit-components/src/components/Route/useRoute.test.ts new file mode 100644 index 0000000000..a2970cbdc3 --- /dev/null +++ b/packages/lit-components/src/components/Route/useRoute.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useRoute } from './useRoute'; + +describe('useRoute', () => { + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + vi.restoreAllMocks(); + vi.stubGlobal('window', { + location: { hash: '' }, + addEventListener: vi.fn(), + }); + }); + + it('should check an exact hash match', () => { + window.location.hash = '#/dashboard'; + const addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(() => undefined); + + const result = useRoute({ path: '/dashboard', exact: true }); + + expect(result.visible).toBe(false); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(true); + expect(addEventListenerSpy).toHaveBeenCalledWith('hashchange', expect.any(Function)); + }); + + it('should fail an exact hash match when the paths differ', () => { + window.location.hash = '#/dashboard/settings'; + vi.spyOn(window, 'addEventListener').mockImplementation(() => undefined); + + useRoute({ path: '/dashboard', exact: true }); + + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(false); + }); + + it('should allow prefix matching when exact is false', () => { + window.location.hash = '#/dashboard/settings'; + vi.spyOn(window, 'addEventListener').mockImplementation(() => undefined); + + useRoute({ path: '/dashboard', exact: false }); + + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(true); + }); +}); diff --git a/packages/lit-components/src/components/Route/useRoute.ts b/packages/lit-components/src/components/Route/useRoute.ts new file mode 100644 index 0000000000..066ac9cf81 --- /dev/null +++ b/packages/lit-components/src/components/Route/useRoute.ts @@ -0,0 +1,29 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { RouteProps } from './Route'; + +export function useRoute(props: RouteProps) { + const [visible, setVisible] = useState(false); + + function checkPath() { + if (typeof window === 'undefined') { + setVisible(false); + return; + } + + const currentPath = window.location.hash.slice(1) || '/'; + + if (props.exact) { + setVisible(currentPath === props.path); + } else { + setVisible(currentPath.startsWith(props.path)); + } + } + + checkPath(); + + if (typeof window !== 'undefined') { + window.addEventListener('hashchange', checkPath); + } + + return { visible }; +} diff --git a/packages/lit-components/src/components/Router/Router.stories.ts b/packages/lit-components/src/components/Router/Router.stories.ts new file mode 100644 index 0000000000..d451a21acd --- /dev/null +++ b/packages/lit-components/src/components/Router/Router.stories.ts @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import { UI } from '../../ui'; +import '../../index'; + +const meta: Meta = { + title: 'Components/Router', + component: 'sql-router', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + window.location.hash = '#/overview'; + + return html` +
+
+ ${UI.Button({ onClick: () => document.dispatchEvent(new CustomEvent('sq:navigate', { detail: { path: '/overview' } })), children: 'Overview' })} + ${UI.Button({ onClick: () => document.dispatchEvent(new CustomEvent('sq:navigate', { detail: { path: '/rewards' } })), children: 'Rewards' })} +
+ + +

Overview route content

+
+ +

Rewards route content

+
+
+
+ `; + }, +}; diff --git a/packages/lit-components/src/components/Router/Router.ts b/packages/lit-components/src/components/Router/Router.ts new file mode 100644 index 0000000000..a1d1b07fd9 --- /dev/null +++ b/packages/lit-components/src/components/Router/Router.ts @@ -0,0 +1,17 @@ +import { useComponent } from '../../hooks/useComponent'; +import { RouterView } from './RouterView'; +import { useRouter } from './useRouter'; + +export interface RouterProps {} + +declare global { + interface HTMLElementTagNameMap { + 'sql-router': HTMLElement; + } +} + +export const Router = useComponent((_host) => { + const hookProps = useRouter(); + + return RouterView({ ...hookProps }); +}, 'sql-router'); diff --git a/packages/lit-components/src/components/Router/RouterView.ts b/packages/lit-components/src/components/Router/RouterView.ts new file mode 100644 index 0000000000..71487fa60e --- /dev/null +++ b/packages/lit-components/src/components/Router/RouterView.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +import { useRouter } from './useRouter'; + +export function RouterView(_props: ReturnType) { + return html` + + + `; +} diff --git a/packages/lit-components/src/components/Router/index.ts b/packages/lit-components/src/components/Router/index.ts new file mode 100644 index 0000000000..44f7dc8c32 --- /dev/null +++ b/packages/lit-components/src/components/Router/index.ts @@ -0,0 +1 @@ +export * from './Router'; diff --git a/packages/lit-components/src/components/Router/useRouter.test.ts b/packages/lit-components/src/components/Router/useRouter.test.ts new file mode 100644 index 0000000000..ce2b599a58 --- /dev/null +++ b/packages/lit-components/src/components/Router/useRouter.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useRouter } from './useRouter'; + +describe('useRouter', () => { + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + vi.restoreAllMocks(); + vi.stubGlobal('window', { + location: { hash: '' }, + addEventListener: vi.fn(), + }); + vi.stubGlobal('document', { + addEventListener: vi.fn(), + }); + }); + + it('should return / as the initial path when there is no hash', () => { + vi.spyOn(window, 'addEventListener').mockImplementation(() => undefined); + vi.spyOn(document, 'addEventListener').mockImplementation(() => undefined); + + const result = useRouter(); + + expect(result.currentPath).toBe('/'); + expect(universalHooksMock.useState).toHaveBeenCalledWith('/'); + }); + + it('should return the current hash path', () => { + window.location.hash = '#/account'; + vi.spyOn(window, 'addEventListener').mockImplementation(() => undefined); + vi.spyOn(document, 'addEventListener').mockImplementation(() => undefined); + + const result = useRouter(); + + expect(result.currentPath).toBe('/account'); + }); + + it('should update currentPath from hashchange and sq:navigate listeners', () => { + let hashChangeListener: EventListener | undefined; + let navigateListener: EventListener | undefined; + + vi.spyOn(window, 'addEventListener').mockImplementation(((type, listener) => { + if (type === 'hashchange') { + hashChangeListener = listener as EventListener; + } + }) as typeof window.addEventListener); + + vi.spyOn(document, 'addEventListener').mockImplementation(((type, listener) => { + if (type === 'sq:navigate') { + navigateListener = listener as EventListener; + } + }) as typeof document.addEventListener); + + useRouter(); + + window.location.hash = '#/profile'; + hashChangeListener?.(new Event('hashchange')); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith('/profile'); + + navigateListener?.(new CustomEvent('sq:navigate', { detail: { path: '/rewards' } })); + expect(window.location.hash).toBe('/rewards'); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith('/rewards'); + }); +}); diff --git a/packages/lit-components/src/components/Router/useRouter.ts b/packages/lit-components/src/components/Router/useRouter.ts new file mode 100644 index 0000000000..e2e3d8e9f2 --- /dev/null +++ b/packages/lit-components/src/components/Router/useRouter.ts @@ -0,0 +1,23 @@ +import { useState } from '@saasquatch/universal-hooks'; + +export function useRouter() { + const [currentPath, setCurrentPath] = useState( + typeof window !== 'undefined' ? window.location.hash.slice(1) || '/' : '/' + ); + + if (typeof window !== 'undefined') { + window.addEventListener('hashchange', () => { + setCurrentPath(window.location.hash.slice(1) || '/'); + }); + + document.addEventListener( + 'sq:navigate', + ((event: CustomEvent) => { + window.location.hash = event.detail.path; + setCurrentPath(event.detail.path); + }) as EventListener + ); + } + + return { currentPath }; +} diff --git a/packages/lit-components/src/components/Scroll/Scroll.stories.ts b/packages/lit-components/src/components/Scroll/Scroll.stories.ts new file mode 100644 index 0000000000..eab136348f --- /dev/null +++ b/packages/lit-components/src/components/Scroll/Scroll.stories.ts @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const longVerticalContent = html` +
+ ${Array.from({ length: 10 }, (_, index) => html` +
+ Scroll item ${index + 1} +
+ `)} +
+`; + +const wideHorizontalContent = html` +
+ ${Array.from({ length: 8 }, (_, index) => html` +
+ Card ${index + 1} +
+ `)} +
+`; + +const gridContent = html` +
+ ${Array.from({ length: 12 }, (_, index) => html` +
+ Panel ${index + 1} +
+ `)} +
+`; + +const meta: Meta = { + title: 'Components/Scroll', + component: 'sql-scroll', + tags: ['autodocs'], + argTypes: { + 'max-height': { control: 'text' }, + 'scroll-direction': { control: 'select', options: ['vertical', 'horizontal', 'both'] }, + }, + render: (args) => html` + + ${args['scroll-direction'] === 'horizontal' + ? wideHorizontalContent + : args['scroll-direction'] === 'both' + ? gridContent + : longVerticalContent} + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + 'scroll-direction': 'vertical', + }, +}; + +export const Horizontal: Story = { + args: { + 'scroll-direction': 'horizontal', + 'max-height': '220px', + }, +}; + +export const CustomHeight: Story = { + args: { + 'max-height': '240px', + }, +}; + +export const BothDirections: Story = { + args: { + 'scroll-direction': 'both', + 'max-height': '260px', + }, +}; diff --git a/packages/lit-components/src/components/Scroll/Scroll.ts b/packages/lit-components/src/components/Scroll/Scroll.ts new file mode 100644 index 0000000000..bd64faaad7 --- /dev/null +++ b/packages/lit-components/src/components/Scroll/Scroll.ts @@ -0,0 +1,34 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ScrollView } from './ScrollView'; +import { useScroll } from './useScroll'; + +export interface ScrollProps { + maxHeight?: string; + scrollDirection?: 'vertical' | 'horizontal' | 'both'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-scroll': HTMLElement; + } +} + +export const Scroll = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: ScrollProps = { + maxHeight: typeof rawProps.maxHeight === 'string' ? rawProps.maxHeight : '400px', + scrollDirection: + rawProps.scrollDirection === 'horizontal' || rawProps.scrollDirection === 'both' + ? rawProps.scrollDirection + : 'vertical', + }; + + const hookProps = useScroll(props); + + return ScrollView({ ...props, ...hookProps }); + }, + 'sql-scroll', + ['max-height', 'scroll-direction'] as const +); diff --git a/packages/lit-components/src/components/Scroll/ScrollView.ts b/packages/lit-components/src/components/Scroll/ScrollView.ts new file mode 100644 index 0000000000..d2863b2cf2 --- /dev/null +++ b/packages/lit-components/src/components/Scroll/ScrollView.ts @@ -0,0 +1,24 @@ +import { html } from 'lit'; +import { ScrollProps } from './Scroll'; +import { useScroll } from './useScroll'; + +export function ScrollView(props: ScrollProps & ReturnType) { + const hookProps = props; + + return html` + +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/Scroll/index.ts b/packages/lit-components/src/components/Scroll/index.ts new file mode 100644 index 0000000000..2b89663869 --- /dev/null +++ b/packages/lit-components/src/components/Scroll/index.ts @@ -0,0 +1 @@ +export * from './Scroll'; diff --git a/packages/lit-components/src/components/Scroll/useScroll.test.ts b/packages/lit-components/src/components/Scroll/useScroll.test.ts new file mode 100644 index 0000000000..b41f0aee17 --- /dev/null +++ b/packages/lit-components/src/components/Scroll/useScroll.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useScroll } from './useScroll'; + +describe('useScroll', () => { + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + }); + + it('should return initial scroll state', () => { + const result = useScroll({}); + + expect(result.scrollTop).toBe(0); + expect(result.isScrolled).toBe(false); + }); + + it('should have onScroll handler', () => { + const result = useScroll({}); + + result.onScroll({ target: { scrollTop: 24 } } as unknown as Event); + + expect(typeof result.onScroll).toBe('function'); + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(24); + }); +}); diff --git a/packages/lit-components/src/components/Scroll/useScroll.ts b/packages/lit-components/src/components/Scroll/useScroll.ts new file mode 100644 index 0000000000..50552b0ada --- /dev/null +++ b/packages/lit-components/src/components/Scroll/useScroll.ts @@ -0,0 +1,13 @@ +import { useState } from '@saasquatch/universal-hooks'; +import { ScrollProps } from './Scroll'; + +export function useScroll(_props: ScrollProps) { + const [scrollTop, setScrollTop] = useState(0); + + function onScroll(e: Event) { + const target = e.target as HTMLElement; + setScrollTop(target.scrollTop); + } + + return { scrollTop, onScroll, isScrolled: scrollTop > 0 }; +} diff --git a/packages/lit-components/src/components/ShareButton/ShareButton.stories.ts b/packages/lit-components/src/components/ShareButton/ShareButton.stories.ts new file mode 100644 index 0000000000..fa0896de71 --- /dev/null +++ b/packages/lit-components/src/components/ShareButton/ShareButton.stories.ts @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = '******'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: '******', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/ShareButton', + component: 'sql-share-button', + tags: ['autodocs'], + argTypes: { + medium: { + control: 'select', + options: ['facebook', 'twitter', 'email', 'sms', 'linkedin', 'pinterest', 'whatsapp'], + }, + 'icon-slot': { control: 'select', options: ['prefix', 'suffix', 'label'] }, + 'button-text': { control: 'text' }, + 'hide-text': { control: 'boolean' }, + 'hide-icon': { control: 'boolean' }, + 'background-color': { control: 'color' }, + 'text-color': { control: 'color' }, + 'border-radius': { control: 'number' }, + 'pill-layout': { control: 'boolean' }, + size: { control: 'select', options: ['small', 'medium', 'large'] }, + }, + render: (args) => { + setupGlobals(); + return html``; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Facebook: Story = {}; + +export const Twitter: Story = { args: { medium: 'twitter' } }; + +export const Email: Story = { args: { medium: 'email' } }; + +export const SMS: Story = { args: { medium: 'sms' } }; + +export const LinkedIn: Story = { args: { medium: 'linkedin' } }; + +export const WhatsApp: Story = { args: { medium: 'whatsapp' } }; + +export const Pinterest: Story = { args: { medium: 'pinterest' } }; + +export const CustomColors: Story = { + args: { + 'background-color': '#111827', + 'text-color': '#f9fafb', + }, +}; + +export const PillLayout: Story = { + args: { + 'pill-layout': true, + }, +}; + +export const IconOnly: Story = { + args: { + 'hide-text': true, + }, +}; + +export const TextOnly: Story = { + args: { + 'hide-icon': true, + }, +}; + +export const LargeSize: Story = { + args: { + size: 'large', + }, +}; diff --git a/packages/lit-components/src/components/ShareButton/ShareButton.ts b/packages/lit-components/src/components/ShareButton/ShareButton.ts new file mode 100644 index 0000000000..67bae1d431 --- /dev/null +++ b/packages/lit-components/src/components/ShareButton/ShareButton.ts @@ -0,0 +1,75 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ShareButtonView } from './ShareButtonView'; +import { useDemoShareButton, useShareButton } from './useShareButton'; + +export type ShareButtonMedium = + | 'facebook' + | 'twitter' + | 'email' + | 'sms' + | 'linkedin' + | 'pinterest' + | 'whatsapp' + | 'linemessenger' + | 'reminder' + | 'fbmessenger'; + +export interface ShareButtonProps { + medium: ShareButtonMedium; + iconSlot: 'prefix' | 'suffix' | 'label'; + buttonText?: string; + hideText: boolean; + hideIcon: boolean; + programId?: string; + emailSubject?: string; + emailBody?: string; + messageText?: string; + backgroundColor?: string; + textColor?: string; + borderRadius?: number; + pillLayout: boolean; + size: 'small' | 'medium' | 'large'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-share-button': HTMLElement; + } +} + +export const ShareButton = useComponent( + (host) => { + const props: ShareButtonProps = { + medium: 'facebook', + iconSlot: 'prefix', + hideText: false, + hideIcon: false, + pillLayout: false, + size: 'medium', + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoShareButton(props) : useShareButton(props); + + return ShareButtonView({ ...props, ...hookProps }); + }, + 'sql-share-button', + [ + 'medium', + 'icon-slot', + 'button-text', + 'hide-text', + 'hide-icon', + 'program-id', + 'email-subject', + 'email-body', + 'message-text', + 'background-color', + 'text-color', + 'border-radius', + 'pill-layout', + 'size', + ] as const +); diff --git a/packages/lit-components/src/components/ShareButton/ShareButtonView.ts b/packages/lit-components/src/components/ShareButton/ShareButtonView.ts new file mode 100644 index 0000000000..8f98160a9f --- /dev/null +++ b/packages/lit-components/src/components/ShareButton/ShareButtonView.ts @@ -0,0 +1,112 @@ +import { html, nothing } from 'lit'; +import { UI } from '../../ui'; +import { ShareButtonMedium, ShareButtonProps } from './ShareButton'; +import { useShareButton } from './useShareButton'; + +const defaultColors: Record = { + facebook: '#1877f2', + twitter: '#1da1f2', + email: '#6c757d', + sms: '#6c757d', + linkedin: '#0077b5', + pinterest: '#bd081c', + whatsapp: '#25d366', + linemessenger: '#06c755', + reminder: '#6c757d', + fbmessenger: '#0084ff', +}; + +const defaultText: Record = { + facebook: 'Share on Facebook', + twitter: 'Tweet', + email: 'Send Email', + sms: 'Send SMS', + linkedin: 'Share on LinkedIn', + pinterest: 'Pin it', + whatsapp: 'Share on WhatsApp', + linemessenger: 'Share on LINE', + reminder: 'Set Reminder', + fbmessenger: 'Share on Messenger', +}; + +const iconNames: Record = { + facebook: 'facebook', + twitter: 'twitter', + email: 'envelope', + sms: 'chat-dots', + linkedin: 'linkedin', + pinterest: 'pinterest', + whatsapp: 'whatsapp', + linemessenger: 'line', + reminder: 'bell', + fbmessenger: 'messenger', +}; + +const fallbackIconNames: Partial> = { + linemessenger: 'chat', + fbmessenger: 'chat-dots', +}; + +function getDefaultColor(medium: ShareButtonMedium) { + return defaultColors[medium] || '#6c757d'; +} + +function getButtonText(props: ShareButtonProps) { + return props.buttonText || defaultText[props.medium || 'facebook']; +} + +function getIconName(medium: ShareButtonMedium) { + return iconNames[medium] || fallbackIconNames[medium] || 'share'; +} + +export function ShareButtonView(props: ShareButtonProps & ReturnType) { + const medium = props.medium || 'facebook'; + const buttonText = getButtonText(props); + const iconName = getIconName(medium); + const borderRadius = props.borderRadius + ? `${props.borderRadius}px` + : props.pillLayout + ? '999px' + : 'var(--sl-border-radius-medium)'; + + const styles = ` + :host { + display: inline-block; + } + + sl-button::part(base) { + background: ${props.backgroundColor || getDefaultColor(medium)}; + color: ${props.textColor || '#ffffff'}; + border-radius: ${borderRadius}; + border: none; + } + + sl-button::part(label), + sl-button::part(prefix), + sl-button::part(suffix), + sl-icon::part(base) { + color: ${props.textColor || '#ffffff'}; + } + `; + + const labelIconTemplate = props.hideIcon ? nothing : UI.Icon({ name: iconName }); + const slottedIconTemplate = props.hideIcon + ? nothing + : UI.Icon({ name: iconName, slot: props.iconSlot }); + + return html` + + ${UI.Button({ + onClick: () => props.onClick?.(), + size: props.size || 'medium', + disabled: props.disabled, + children: html` + ${props.iconSlot === 'label' ? labelIconTemplate : nothing} + ${props.hideText ? nothing : buttonText} + ${props.iconSlot !== 'label' ? slottedIconTemplate : nothing} + `, + })} + `; +} diff --git a/packages/lit-components/src/components/ShareButton/index.ts b/packages/lit-components/src/components/ShareButton/index.ts new file mode 100644 index 0000000000..8304fcc265 --- /dev/null +++ b/packages/lit-components/src/components/ShareButton/index.ts @@ -0,0 +1 @@ +export * from './ShareButton'; diff --git a/packages/lit-components/src/components/ShareButton/useShareButton.test.ts b/packages/lit-components/src/components/ShareButton/useShareButton.test.ts new file mode 100644 index 0000000000..4f1d170684 --- /dev/null +++ b/packages/lit-components/src/components/ShareButton/useShareButton.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt', id: 'user1', accountId: 'acc1' })), + useEngagementMedium: vi.fn(() => 'EMBED'), + useQuery: vi.fn(() => ({ + data: { + user: { + shareLink: 'https://example.com/share', + referralCode: 'CODE123', + messageLink: 'https://example.com/msg', + }, + }, + loading: false, + })), + useMutation: vi.fn(() => [vi.fn()]), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { useShareButton } from './useShareButton'; + +describe('useShareButton', () => { + it('should return share link and onClick', () => { + const result = useShareButton({ medium: 'facebook', programId: '' } as any); + + expect(result.shareLink).toBe('https://example.com/share'); + expect(result.loading).toBe(false); + expect(typeof result.onClick).toBe('function'); + }); + + it('should not be disabled when not loading', () => { + const result = useShareButton({ medium: 'twitter', programId: '' } as any); + + expect(result.disabled).toBe(false); + }); +}); diff --git a/packages/lit-components/src/components/ShareButton/useShareButton.ts b/packages/lit-components/src/components/ShareButton/useShareButton.ts new file mode 100644 index 0000000000..774c74013c --- /dev/null +++ b/packages/lit-components/src/components/ShareButton/useShareButton.ts @@ -0,0 +1,138 @@ +import { + useEngagementMedium, + useMutation, + useProgramId, + useQuery, + useUserIdentity, +} from '@saasquatch/component-boilerplate'; +import { gql } from 'graphql-request'; +import { ShareButtonProps } from './ShareButton'; + +const ShareLinkQuery = gql` + query getShareLink($programId: ID) { + user: viewer { + ... on User { + shareLink(programId: $programId) + referralCode(programId: $programId) + messageLink(programId: $programId, shareMedium: UNKNOWN, engagementMedium: EMBED) + } + } + } +`; + +const WIDGET_ENGAGEMENT_EVENT = gql` + mutation loadEvent($eventMeta: UserAnalyticsEvent!) { + createUserAnalyticsEvent(eventMeta: $eventMeta) + } +`; + +export function useShareButton(props: ShareButtonProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const engagementMedium = useEngagementMedium(); + const medium = props.medium || 'facebook'; + const { data, loading } = useQuery(ShareLinkQuery, { programId }, !user?.jwt); + const [sendLoadEvent] = useMutation(WIDGET_ENGAGEMENT_EVENT); + + const shareLink = data?.user?.shareLink || ''; + const messageLink = data?.user?.messageLink || shareLink; + + function getShareUrl() { + const text = props.messageText || `Check this out: ${shareLink}`; + const subject = props.emailSubject || 'Check this out!'; + const body = props.emailBody || text; + + switch (medium) { + case 'facebook': + return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareLink)}`; + case 'twitter': + return `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareLink)}&text=${encodeURIComponent(text)}`; + case 'email': + return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + case 'sms': + return `sms:?&body=${encodeURIComponent(text)}`; + case 'linkedin': + return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(shareLink)}`; + case 'pinterest': + return `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(shareLink)}&description=${encodeURIComponent(text)}`; + case 'whatsapp': + return `https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`; + case 'linemessenger': + return `https://line.me/R/msg/text/?${encodeURIComponent(text)}`; + case 'fbmessenger': + return `fb-messenger://share/?link=${encodeURIComponent(shareLink)}`; + case 'reminder': + return messageLink; + default: + return shareLink; + } + } + + function onClick() { + const url = getShareUrl(); + if (medium === 'email' || medium === 'sms') { + window.location.href = url; + } else { + window.open(url, '_blank', 'width=600,height=400'); + } + sendLoadEvent({ + eventMeta: { + programId, + id: user?.id, + accountId: user?.accountId, + type: 'USER_REFERRAL_PROGRAM_ENGAGEMENT_EVENT', + meta: { engagementMedium, shareMedium: medium.toUpperCase() }, + }, + }); + } + + return { onClick, loading, disabled: loading, shareLink }; +} + +export function useDemoShareButton(props: ShareButtonProps) { + const medium = props.medium || 'facebook'; + const shareLink = 'https://www.example.com/sharelink/abc'; + const messageLink = shareLink; + + function getShareUrl() { + const text = props.messageText || `Check this out: ${shareLink}`; + const subject = props.emailSubject || 'Check this out!'; + const body = props.emailBody || text; + + switch (medium) { + case 'facebook': + return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareLink)}`; + case 'twitter': + return `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareLink)}&text=${encodeURIComponent(text)}`; + case 'email': + return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + case 'sms': + return `sms:?&body=${encodeURIComponent(text)}`; + case 'linkedin': + return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(shareLink)}`; + case 'pinterest': + return `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(shareLink)}&description=${encodeURIComponent(text)}`; + case 'whatsapp': + return `https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`; + case 'linemessenger': + return `https://line.me/R/msg/text/?${encodeURIComponent(text)}`; + case 'fbmessenger': + return `fb-messenger://share/?link=${encodeURIComponent(shareLink)}`; + case 'reminder': + return messageLink; + default: + return shareLink; + } + } + + function onClick() { + const url = getShareUrl(); + if (medium === 'email' || medium === 'sms') { + window.location.href = url; + } else { + window.open(url, '_blank', 'width=600,height=400'); + } + } + + return { onClick, loading: false, disabled: false, shareLink }; +} diff --git a/packages/lit-components/src/components/ShareCode/ShareCode.stories.ts b/packages/lit-components/src/components/ShareCode/ShareCode.stories.ts new file mode 100644 index 0000000000..4dc9e86f48 --- /dev/null +++ b/packages/lit-components/src/components/ShareCode/ShareCode.stories.ts @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = '******'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: '******', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/ShareCode', + component: 'sql-share-code', + tags: ['autodocs'], + argTypes: { + 'tooltip-text': { control: 'text' }, + 'tooltip-lifespan': { control: 'number' }, + 'text-align': { control: 'select', options: ['left', 'center', 'right'] }, + 'copy-button-label': { control: 'text' }, + 'button-style': { control: 'select', options: ['icon', 'button-outside', 'button-below'] }, + 'button-type': { control: 'select', options: ['primary', 'secondary'] }, + 'background-color': { control: 'color' }, + 'border-color': { control: 'color' }, + 'text-color': { control: 'color' }, + 'border-radius': { control: 'number' }, + }, + render: (args) => { + setupGlobals(); + return html``; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const ButtonOutside: Story = { + args: { + 'button-style': 'button-outside', + }, +}; + +export const ButtonBelow: Story = { + args: { + 'button-style': 'button-below', + }, +}; + +export const CustomColors: Story = { + args: { + 'background-color': '#f3f4f6', + 'border-color': '#111827', + 'text-color': '#111827', + }, +}; + +export const CustomBorderRadius: Story = { + args: { + 'border-radius': 18, + }, +}; + +export const CustomTooltip: Story = { + args: { + 'tooltip-text': 'Code copied!', + }, +}; diff --git a/packages/lit-components/src/components/ShareCode/ShareCode.ts b/packages/lit-components/src/components/ShareCode/ShareCode.ts new file mode 100644 index 0000000000..3ef2e60cbf --- /dev/null +++ b/packages/lit-components/src/components/ShareCode/ShareCode.ts @@ -0,0 +1,57 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ShareCodeView } from './ShareCodeView'; +import { useDemoShareCode, useShareCode } from './useShareCode'; + +export interface ShareCodeProps { + tooltipText: string; + tooltipLifespan: number; + textAlign: 'left' | 'center' | 'right'; + copyButtonLabel: string; + buttonStyle: 'icon' | 'button-outside' | 'button-below'; + buttonType: 'primary' | 'secondary'; + backgroundColor?: string; + borderColor?: string; + textColor?: string; + borderRadius?: number; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-share-code': HTMLElement; + } +} + +export const ShareCode = useComponent( + (host) => { + const props: ShareCodeProps = { + tooltipText: 'Copied to Clipboard', + tooltipLifespan: 1000, + textAlign: 'left', + copyButtonLabel: 'Copy Code', + buttonStyle: 'icon', + buttonType: 'primary', + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoShareCode(props) : useShareCode(props); + + return ShareCodeView({ ...props, ...hookProps }); + }, + 'sql-share-code', + [ + 'tooltip-text', + 'tooltip-lifespan', + 'text-align', + 'copy-button-label', + 'button-style', + 'button-type', + 'background-color', + 'border-color', + 'text-color', + 'border-radius', + 'program-id', + ] as const +); diff --git a/packages/lit-components/src/components/ShareCode/ShareCodeView.ts b/packages/lit-components/src/components/ShareCode/ShareCodeView.ts new file mode 100644 index 0000000000..445d3d3c2f --- /dev/null +++ b/packages/lit-components/src/components/ShareCode/ShareCodeView.ts @@ -0,0 +1,130 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { ButtonVariant } from '../../ui/types'; +import { ShareCodeProps } from './ShareCode'; +import { useShareCode } from './useShareCode'; + +const baseStyles = ` + :host { + display: block; + } + + sl-input { + width: 100%; + } + + .container-div { + display: flex; + align-items: flex-start; + flex-direction: column; + justify-content: center; + gap: var(--sl-spacing-x-small); + width: 100%; + } + + .container-style { + display: flex; + align-items: center; + gap: var(--sl-spacing-x-small); + width: 100%; + } +`; + +const getDynamicStyles = (props: ShareCodeProps) => ` + .container-div { + text-align: ${props.textAlign || 'left'}; + } + + sl-input::part(input) { + text-overflow: ellipsis; + width: 100%; + text-align: ${props.textAlign || 'left'}; + color: ${props.textColor || 'var(--sl-input-color)'}; + } + + sl-input::part(base) { + --sl-input-border-radius: ${props.borderRadius ? `${props.borderRadius}px` : 'var(--sqm-border-radius-normal)'}; + --sl-input-border-color: var(--sqm-border-color); + --sl-input-border-color-hover: #999999; + --sl-input-border-color-focus: #999999; + --sl-input-color-hover: var(--sqm-input-color-hover); + --sl-input-color-focus: var(--sqm-input-color-focus); + --sl-input-color-disabled: var(--sqm-input-disabled-color); + --sl-input-background-color-focus: var(--sqm-input-background); + --sl-input-background-color-hover: var(--sqm-input-background); + cursor: pointer; + overflow: visible; + border-radius: ${props.borderRadius ? `${props.borderRadius}px` : 'var(--sqm-border-radius-normal)'}; + background: ${props.backgroundColor || 'var(--sqm-input-background)'}; + border: var(--sqm-border-thickness) solid ${props.borderColor || 'var(--sqm-input-border-color)'}; + } + + sl-icon::part(base) { + color: ${props.textColor || 'var(--sqm-text)'}; + } + + sl-icon-button::part(base) { + color: ${props.textColor || 'var(--sqm-text)'}; + } +`; + +export function ShareCodeView(props: ShareCodeProps & ReturnType) { + const dynamicStyles = getDynamicStyles(props); + const buttonStyle = props.buttonStyle || 'icon'; + const disabled = props.loading || props.disabled; + const tooltipPlacement = + props.buttonStyle === 'button-below' + ? 'bottom' + : props.buttonStyle === 'button-outside' + ? 'top' + : 'top-end'; + + const copyButton = html` + ${UI.Tooltip({ + trigger: 'manual', + content: props.tooltipText, + placement: tooltipPlacement, + disabled: props.disabled, + open: props.open, + skidding: props.buttonStyle === 'icon' ? -5 : 0, + slot: 'suffix', + children: buttonStyle === 'icon' + ? UI.IconButton({ + exportparts: 'base: icon-button-base', + onClick: () => props.onClick?.(), + name: 'files', + disabled: disabled, + }) + : UI.Button({ + exportparts: `base: ${props.buttonType || 'primary'}button-base`, + onClick: () => props.onClick?.(), + size: 'medium', + style: buttonStyle === 'button-below' ? 'width: 100%' : '', + disabled: disabled, + variant: (props.buttonType || 'primary') as ButtonVariant, + children: props.copyButtonLabel || 'Copy Code', + }), + })} + `; + + return html` + +
+
+ ${UI.Input({ + value: props.loading ? 'Loading...' : props.copyString, + readonly: true, + disabled: disabled, + suffix: buttonStyle === 'icon' ? copyButton : undefined, + })} + ${buttonStyle === 'button-outside' || buttonStyle === 'button-below' ? copyButton : ''} +
+
+ `; +} diff --git a/packages/lit-components/src/components/ShareCode/index.ts b/packages/lit-components/src/components/ShareCode/index.ts new file mode 100644 index 0000000000..0e4927afee --- /dev/null +++ b/packages/lit-components/src/components/ShareCode/index.ts @@ -0,0 +1 @@ +export * from './ShareCode'; diff --git a/packages/lit-components/src/components/ShareCode/useShareCode.test.ts b/packages/lit-components/src/components/ShareCode/useShareCode.test.ts new file mode 100644 index 0000000000..7f201a1447 --- /dev/null +++ b/packages/lit-components/src/components/ShareCode/useShareCode.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendLoadEventMock = vi.hoisted(() => vi.fn()); + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt', id: 'user1', accountId: 'acc1' })), + useEngagementMedium: vi.fn(() => 'EMBED'), + useQuery: vi.fn(() => ({ data: { user: { referralCode: 'CODE123' } }, loading: false })), + useMutation: vi.fn(() => [sendLoadEventMock]), +})); + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useShareCode } from './useShareCode'; + +describe('useShareCode', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { referralCode: 'CODE123' } }, + loading: false, + }); + componentBoilerplateMock.useMutation.mockReturnValue([sendLoadEventMock]); + }); + + it('returns the referral code as copyString', () => { + const result = useShareCode({ tooltipLifespan: 1000 } as any); + + expect(result.copyString).toBe('CODE123'); + }); + + it('returns loading and disabled from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useShareCode({ tooltipLifespan: 1000 } as any); + + expect(result.loading).toBe(true); + expect(result.disabled).toBe(true); + }); + + it('returns an onClick handler', () => { + const result = useShareCode({ tooltipLifespan: 1000 } as any); + + expect(typeof result.onClick).toBe('function'); + }); + + it('returns ellipsis as the default copy string when no data exists', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: { user: {} }, loading: false }); + + const result = useShareCode({ tooltipLifespan: 1000 } as any); + + expect(result.copyString).toBe('...'); + }); +}); diff --git a/packages/lit-components/src/components/ShareCode/useShareCode.ts b/packages/lit-components/src/components/ShareCode/useShareCode.ts new file mode 100644 index 0000000000..b251e824de --- /dev/null +++ b/packages/lit-components/src/components/ShareCode/useShareCode.ts @@ -0,0 +1,67 @@ +import { + useEngagementMedium, + useMutation, + useProgramId, + useQuery, + useUserIdentity, +} from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { ShareCodeProps } from './ShareCode'; + +const ShareCodeQuery = gql` + query getShareCode($programId: ID) { + user: viewer { + ... on User { + referralCode(programId: $programId) + } + } + } +`; + +const WIDGET_ENGAGEMENT_EVENT = gql` + mutation loadEvent($eventMeta: UserAnalyticsEvent!) { + createUserAnalyticsEvent(eventMeta: $eventMeta) + } +`; + +export function useShareCode(props: ShareCodeProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const engagementMedium = useEngagementMedium(); + const { data, loading } = useQuery(ShareCodeQuery, { programId }, !user?.jwt); + const [sendLoadEvent] = useMutation(WIDGET_ENGAGEMENT_EVENT); + + const copyString = data?.user?.referralCode ?? '...'; + const [open, setOpen] = useState(false); + + function onClick() { + navigator.clipboard.writeText(copyString); + setOpen(true); + setTimeout(() => setOpen(false), props.tooltipLifespan); + sendLoadEvent({ + eventMeta: { + programId, + id: user?.id, + accountId: user?.accountId, + type: 'USER_REFERRAL_PROGRAM_ENGAGEMENT_EVENT', + meta: { engagementMedium, shareMedium: 'DIRECT' }, + }, + }); + } + + return { onClick, open, disabled: loading, loading, copyString, error: '' }; +} + +export function useDemoShareCode(props: ShareCodeProps) { + const [open, setOpen] = useState(false); + const copyString = 'SHARECODE001'; + + function onClick() { + navigator.clipboard.writeText(copyString); + setOpen(true); + setTimeout(() => setOpen(false), props.tooltipLifespan); + } + + return { onClick, open, disabled: false, loading: false, copyString, error: '' }; +} diff --git a/packages/lit-components/src/components/ShareLink/ShareLink.stories.ts b/packages/lit-components/src/components/ShareLink/ShareLink.stories.ts new file mode 100644 index 0000000000..aa3a531434 --- /dev/null +++ b/packages/lit-components/src/components/ShareLink/ShareLink.stories.ts @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = '******'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: '******', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/ShareLink', + component: 'sql-share-link', + tags: ['autodocs'], + argTypes: { + 'tooltip-text': { control: 'text' }, + 'tooltip-lifespan': { control: 'number' }, + 'text-align': { control: 'select', options: ['left', 'center', 'right'] }, + 'copy-button-label': { control: 'text' }, + 'button-style': { control: 'select', options: ['icon', 'button-outside', 'button-below'] }, + 'button-type': { control: 'select', options: ['primary', 'secondary'] }, + 'background-color': { control: 'color' }, + 'border-color': { control: 'color' }, + 'text-color': { control: 'color' }, + 'border-radius': { control: 'number' }, + }, + render: (args) => { + setupGlobals(); + return html``; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const ButtonOutside: Story = { + args: { + 'button-style': 'button-outside', + }, +}; + +export const ButtonBelow: Story = { + args: { + 'button-style': 'button-below', + }, +}; + +export const CustomColors: Story = { + args: { + 'background-color': '#f0f8ff', + 'border-color': '#4169e1', + 'text-color': '#191970', + }, +}; + +export const CustomBorderRadius: Story = { + args: { + 'border-radius': 20, + }, +}; + +export const WithTooltipText: Story = { + args: { + 'tooltip-text': 'Link copied!', + }, +}; diff --git a/packages/lit-components/src/components/ShareLink/ShareLink.ts b/packages/lit-components/src/components/ShareLink/ShareLink.ts new file mode 100644 index 0000000000..9a86bcac30 --- /dev/null +++ b/packages/lit-components/src/components/ShareLink/ShareLink.ts @@ -0,0 +1,57 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { ShareLinkView } from './ShareLinkView'; +import { useDemoShareLink, useShareLink } from './useShareLink'; + +export interface ShareLinkProps { + tooltipText: string; + tooltipLifespan: number; + textAlign: 'left' | 'center' | 'right'; + copyButtonLabel: string; + buttonStyle: 'icon' | 'button-outside' | 'button-below'; + buttonType: 'primary' | 'secondary'; + backgroundColor?: string; + borderColor?: string; + textColor?: string; + borderRadius?: number; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-share-link': HTMLElement; + } +} + +export const ShareLink = useComponent( + (host) => { + const props: ShareLinkProps = { + tooltipText: 'Copied to Clipboard', + tooltipLifespan: 1000, + textAlign: 'left', + copyButtonLabel: 'Copy Link', + buttonStyle: 'icon', + buttonType: 'primary', + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoShareLink(props) : useShareLink(props); + + return ShareLinkView({ ...props, ...hookProps }); + }, + 'sql-share-link', + [ + 'tooltip-text', + 'tooltip-lifespan', + 'text-align', + 'copy-button-label', + 'button-style', + 'button-type', + 'background-color', + 'border-color', + 'text-color', + 'border-radius', + 'program-id', + ] as const +); diff --git a/packages/lit-components/src/components/ShareLink/ShareLinkView.ts b/packages/lit-components/src/components/ShareLink/ShareLinkView.ts new file mode 100644 index 0000000000..1837fc4128 --- /dev/null +++ b/packages/lit-components/src/components/ShareLink/ShareLinkView.ts @@ -0,0 +1,130 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { ButtonVariant } from '../../ui/types'; +import { ShareLinkProps } from './ShareLink'; +import { useShareLink } from './useShareLink'; + +const baseStyles = ` + :host { + display: block; + } + + sl-input { + width: 100%; + } + + .container-div { + display: flex; + align-items: flex-start; + flex-direction: column; + justify-content: center; + gap: var(--sl-spacing-x-small); + width: 100%; + } + + .container-style { + display: flex; + align-items: center; + gap: var(--sl-spacing-x-small); + width: 100%; + } +`; + +const getDynamicStyles = (props: ShareLinkProps) => ` + .container-div { + text-align: ${props.textAlign || 'left'}; + } + + sl-input::part(input) { + text-overflow: ellipsis; + width: 100%; + text-align: ${props.textAlign || 'left'}; + color: ${props.textColor || 'var(--sl-input-color)'}; + } + + sl-input::part(base) { + --sl-input-border-radius: ${props.borderRadius ? `${props.borderRadius}px` : 'var(--sqm-border-radius-normal)'}; + --sl-input-border-color: var(--sqm-border-color); + --sl-input-border-color-hover: #999999; + --sl-input-border-color-focus: #999999; + --sl-input-color-hover: var(--sqm-input-color-hover); + --sl-input-color-focus: var(--sqm-input-color-focus); + --sl-input-color-disabled: var(--sqm-input-disabled-color); + --sl-input-background-color-focus: var(--sqm-input-background); + --sl-input-background-color-hover: var(--sqm-input-background); + cursor: pointer; + overflow: visible; + border-radius: ${props.borderRadius ? `${props.borderRadius}px` : 'var(--sqm-border-radius-normal)'}; + background: ${props.backgroundColor || 'var(--sqm-input-background)'}; + border: var(--sqm-border-thickness) solid ${props.borderColor || 'var(--sqm-input-border-color)'}; + } + + sl-icon::part(base) { + color: ${props.textColor || 'var(--sqm-text)'}; + } + + sl-icon-button::part(base) { + color: ${props.textColor || 'var(--sqm-text)'}; + } +`; + +export function ShareLinkView(props: ShareLinkProps & ReturnType) { + const dynamicStyles = getDynamicStyles(props); + const buttonStyle = props.buttonStyle || 'icon'; + const disabled = props.loading || props.disabled; + const tooltipPlacement = + props.buttonStyle === 'button-below' + ? 'bottom' + : props.buttonStyle === 'button-outside' + ? 'top' + : 'top-end'; + + const copyButton = html` + ${UI.Tooltip({ + trigger: 'manual', + content: props.tooltipText, + placement: tooltipPlacement, + disabled: props.disabled, + open: props.open, + skidding: props.buttonStyle === 'icon' ? -5 : 0, + slot: 'suffix', + children: buttonStyle === 'icon' + ? UI.IconButton({ + exportparts: 'base: icon-button-base', + onClick: () => props.onClick?.(), + name: 'files', + disabled: disabled, + }) + : UI.Button({ + exportparts: `base: ${props.buttonType || 'primary'}button-base`, + onClick: () => props.onClick?.(), + size: 'medium', + style: buttonStyle === 'button-below' ? 'width: 100%' : '', + disabled: disabled, + variant: (props.buttonType || 'primary') as ButtonVariant, + children: props.copyButtonLabel || 'Copy Link', + }), + })} + `; + + return html` + +
+
+ ${UI.Input({ + value: props.loading ? 'Loading...' : props.copyString, + readonly: true, + disabled: disabled, + suffix: buttonStyle === 'icon' ? copyButton : undefined, + })} + ${buttonStyle === 'button-outside' || buttonStyle === 'button-below' ? copyButton : ''} +
+
+ `; +} diff --git a/packages/lit-components/src/components/ShareLink/index.ts b/packages/lit-components/src/components/ShareLink/index.ts new file mode 100644 index 0000000000..7b634fb069 --- /dev/null +++ b/packages/lit-components/src/components/ShareLink/index.ts @@ -0,0 +1 @@ +export * from './ShareLink'; diff --git a/packages/lit-components/src/components/ShareLink/useShareLink.test.ts b/packages/lit-components/src/components/ShareLink/useShareLink.test.ts new file mode 100644 index 0000000000..ccbb7552b0 --- /dev/null +++ b/packages/lit-components/src/components/ShareLink/useShareLink.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendLoadEventMock = vi.hoisted(() => vi.fn()); + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt', id: 'user1', accountId: 'acc1' })), + useEngagementMedium: vi.fn(() => 'EMBED'), + useQuery: vi.fn(() => ({ data: { user: { shareLink: 'https://example.com/share' } }, loading: false })), + useMutation: vi.fn(() => [sendLoadEventMock]), +})); + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useShareLink } from './useShareLink'; + +describe('useShareLink', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { user: { shareLink: 'https://example.com/share' } }, + loading: false, + }); + componentBoilerplateMock.useMutation.mockReturnValue([sendLoadEventMock]); + }); + + it('returns the share link as copyString', () => { + const result = useShareLink({ tooltipLifespan: 1000 } as any); + + expect(result.copyString).toBe('https://example.com/share'); + }); + + it('returns loading and disabled from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useShareLink({ tooltipLifespan: 1000 } as any); + + expect(result.loading).toBe(true); + expect(result.disabled).toBe(true); + }); + + it('returns an onClick handler', () => { + const result = useShareLink({ tooltipLifespan: 1000 } as any); + + expect(typeof result.onClick).toBe('function'); + }); + + it('returns ellipsis as the default copy string when no data exists', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: { user: {} }, loading: false }); + + const result = useShareLink({ tooltipLifespan: 1000 } as any); + + expect(result.copyString).toBe('...'); + }); +}); diff --git a/packages/lit-components/src/components/ShareLink/useShareLink.ts b/packages/lit-components/src/components/ShareLink/useShareLink.ts new file mode 100644 index 0000000000..f9f6396db1 --- /dev/null +++ b/packages/lit-components/src/components/ShareLink/useShareLink.ts @@ -0,0 +1,71 @@ +import { + useEngagementMedium, + useMutation, + useProgramId, + useQuery, + useUserIdentity, +} from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { ShareLinkProps } from './ShareLink'; + +const ShareLinkQuery = gql` + query getShareLink($programId: ID, $engagementMedium: UserEngagementMedium!) { + user: viewer { + ... on User { + shareLink( + programId: $programId + engagementMedium: $engagementMedium + shareMedium: DIRECT + ) + } + } + } +`; + +const WIDGET_ENGAGEMENT_EVENT = gql` + mutation loadEvent($eventMeta: UserAnalyticsEvent!) { + createUserAnalyticsEvent(eventMeta: $eventMeta) + } +`; + +export function useShareLink(props: ShareLinkProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const engagementMedium = useEngagementMedium(); + const { data, loading } = useQuery(ShareLinkQuery, { programId, engagementMedium }, !user?.jwt); + const [sendLoadEvent] = useMutation(WIDGET_ENGAGEMENT_EVENT); + + const copyString = data?.user?.shareLink ?? '...'; + const [open, setOpen] = useState(false); + + function onClick() { + navigator.clipboard.writeText(copyString); + setOpen(true); + setTimeout(() => setOpen(false), props.tooltipLifespan); + sendLoadEvent({ + eventMeta: { + programId, + id: user?.id, + accountId: user?.accountId, + type: 'USER_REFERRAL_PROGRAM_ENGAGEMENT_EVENT', + meta: { engagementMedium, shareMedium: 'DIRECT' }, + }, + }); + } + + return { onClick, open, disabled: loading, loading, copyString, error: '' }; +} + +export function useDemoShareLink(props: ShareLinkProps) { + const [open, setOpen] = useState(false); + const copyString = 'https://www.example.com/sharelink/abc'; + + function onClick() { + navigator.clipboard.writeText(copyString); + setOpen(true); + setTimeout(() => setOpen(false), props.tooltipLifespan); + } + + return { onClick, open, disabled: false, loading: false, copyString, error: '' }; +} diff --git a/packages/lit-components/src/components/StatContainer/StatContainer.stories.ts b/packages/lit-components/src/components/StatContainer/StatContainer.stories.ts new file mode 100644 index 0000000000..b307960852 --- /dev/null +++ b/packages/lit-components/src/components/StatContainer/StatContainer.stories.ts @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const statCard = (label: string, value: string) => html` +
+
${value}
+
${label}
+
+`; + +const meta: Meta = { + title: 'Components/StatContainer', + component: 'sql-stat-container', + tags: ['autodocs'], + argTypes: { + columns: { control: 'number' }, + gap: { control: 'text' }, + padding: { control: 'text' }, + }, + render: (args) => html` + + ${statCard('Referrals', '24')} + ${statCard('Rewards', '$480')} + ${statCard('Clicks', '1.2k')} + ${statCard('Conversion', '12%')} + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + columns: 3, + }, +}; + +export const TwoColumns: Story = { + args: { + columns: 2, + }, +}; + +export const FourColumns: Story = { + args: { + columns: 4, + }, +}; + +export const CustomGap: Story = { + args: { + gap: '2rem', + }, +}; diff --git a/packages/lit-components/src/components/StatContainer/StatContainer.ts b/packages/lit-components/src/components/StatContainer/StatContainer.ts new file mode 100644 index 0000000000..dfb8e4dc80 --- /dev/null +++ b/packages/lit-components/src/components/StatContainer/StatContainer.ts @@ -0,0 +1,36 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { StatContainerView } from './StatContainerView'; + +export interface StatContainerProps { + columns?: number; + gap?: string; + padding?: string; +} + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && value.trim() !== '') return Number(value); + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-stat-container': HTMLElement; + } +} + +export const StatContainer = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: StatContainerProps = { + columns: parseNumber(rawProps.columns, 3), + gap: typeof rawProps.gap === 'string' ? rawProps.gap : 'var(--sl-spacing-medium)', + padding: typeof rawProps.padding === 'string' ? rawProps.padding : 'var(--sl-spacing-large)', + }; + + return StatContainerView(props); + }, + 'sql-stat-container', + ['columns', 'gap', 'padding'] as const +); diff --git a/packages/lit-components/src/components/StatContainer/StatContainerView.ts b/packages/lit-components/src/components/StatContainer/StatContainerView.ts new file mode 100644 index 0000000000..51ed41e9b1 --- /dev/null +++ b/packages/lit-components/src/components/StatContainer/StatContainerView.ts @@ -0,0 +1,32 @@ +import { html } from 'lit'; +import { StatContainerProps } from './StatContainer'; + +export function StatContainerView(props: StatContainerProps) { + const columns = props.columns || 3; + const gap = props.gap || 'var(--sl-spacing-medium)'; + const padding = props.padding || 'var(--sl-spacing-large)'; + + return html` + +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/StatContainer/index.ts b/packages/lit-components/src/components/StatContainer/index.ts new file mode 100644 index 0000000000..7f6d761cf3 --- /dev/null +++ b/packages/lit-components/src/components/StatContainer/index.ts @@ -0,0 +1 @@ +export * from './StatContainer'; diff --git a/packages/lit-components/src/components/Tab/Tab.stories.ts b/packages/lit-components/src/components/Tab/Tab.stories.ts new file mode 100644 index 0000000000..93c246c35f --- /dev/null +++ b/packages/lit-components/src/components/Tab/Tab.stories.ts @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/Tab', + component: 'sql-tab', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => html` + + Overview tab content + Rewards tab content + + `, +}; + +export const Disabled: Story = { + render: () => html` + + Overview tab content + Rewards tab content + + `, +}; diff --git a/packages/lit-components/src/components/Tab/Tab.ts b/packages/lit-components/src/components/Tab/Tab.ts new file mode 100644 index 0000000000..b1acdc2c83 --- /dev/null +++ b/packages/lit-components/src/components/Tab/Tab.ts @@ -0,0 +1,29 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { TabView } from './TabView'; + +export interface TabProps { + header: string; + disabled?: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-tab': HTMLElement; + } +} + +export const Tab = useComponent( + (host) => { + const rawProps = getProps(host); + const props: TabProps = { + ...rawProps, + header: rawProps.header || '', + disabled: host.hasAttribute('disabled') && host.getAttribute('disabled') !== 'false', + }; + + return TabView(props); + }, + 'sql-tab', + ['header', 'disabled'] as const +); diff --git a/packages/lit-components/src/components/Tab/TabView.ts b/packages/lit-components/src/components/Tab/TabView.ts new file mode 100644 index 0000000000..76cbde24c1 --- /dev/null +++ b/packages/lit-components/src/components/Tab/TabView.ts @@ -0,0 +1,15 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { TabProps } from './Tab'; + +export function TabView(props: TabProps) { + return html` + + ${UI.Tab({ panel: props.header, disabled: props.disabled, children: props.header })} + ${UI.TabPanel({ name: props.header, children: html`` })} + `; +} diff --git a/packages/lit-components/src/components/Tab/index.ts b/packages/lit-components/src/components/Tab/index.ts new file mode 100644 index 0000000000..2e2986cb59 --- /dev/null +++ b/packages/lit-components/src/components/Tab/index.ts @@ -0,0 +1 @@ +export * from './Tab'; diff --git a/packages/lit-components/src/components/TableCell/TableCell.stories.ts b/packages/lit-components/src/components/TableCell/TableCell.stories.ts new file mode 100644 index 0000000000..aa0552857b --- /dev/null +++ b/packages/lit-components/src/components/TableCell/TableCell.stories.ts @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/TableCell', + component: 'sql-table-cell', + tags: ['autodocs'], + render: (args) => html` +
+
+ + Example cell content + +
+
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + alignment: 'left', + fontWeight: 'normal', + }, +}; + +export const CenterAligned: Story = { + args: { + alignment: 'center', + }, +}; + +export const RightAligned: Story = { + args: { + alignment: 'right', + }, +}; + +export const CustomWidth: Story = { + args: { + width: '240px', + fontWeight: '600', + }, +}; diff --git a/packages/lit-components/src/components/TableCell/TableCell.ts b/packages/lit-components/src/components/TableCell/TableCell.ts new file mode 100644 index 0000000000..079f6ec7c8 --- /dev/null +++ b/packages/lit-components/src/components/TableCell/TableCell.ts @@ -0,0 +1,31 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { TableCellView } from './TableCellView'; + +export interface TableCellProps { + alignment: 'left' | 'center' | 'right'; + width?: string; + fontWeight?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-table-cell': HTMLElement; + } +} + +export const TableCell = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const alignment = rawProps.alignment; + const props: TableCellProps = { + alignment: alignment === 'center' || alignment === 'right' ? alignment : 'left', + width: typeof rawProps.width === 'string' ? rawProps.width : undefined, + fontWeight: typeof rawProps.fontWeight === 'string' ? rawProps.fontWeight : undefined, + }; + + return TableCellView(props); + }, + 'sql-table-cell', + ['alignment', 'width', 'font-weight'] as const +); diff --git a/packages/lit-components/src/components/TableCell/TableCellView.ts b/packages/lit-components/src/components/TableCell/TableCellView.ts new file mode 100644 index 0000000000..3688d5ae53 --- /dev/null +++ b/packages/lit-components/src/components/TableCell/TableCellView.ts @@ -0,0 +1,12 @@ +import { html } from 'lit'; +import { TableCellProps } from './TableCell'; + +export function TableCellView(props: TableCellProps) { + return html` + +
+ `; +} diff --git a/packages/lit-components/src/components/TableCell/index.ts b/packages/lit-components/src/components/TableCell/index.ts new file mode 100644 index 0000000000..4b93e4012b --- /dev/null +++ b/packages/lit-components/src/components/TableCell/index.ts @@ -0,0 +1 @@ +export * from './TableCell'; diff --git a/packages/lit-components/src/components/TableRow/TableRow.stories.ts b/packages/lit-components/src/components/TableRow/TableRow.stories.ts new file mode 100644 index 0000000000..c9f22dbb49 --- /dev/null +++ b/packages/lit-components/src/components/TableRow/TableRow.stories.ts @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/TableRow', + component: 'sql-table-row', + tags: ['autodocs'], + render: (args) => html` +
+ +
Taylor Smith
+
$25.00
+
Converted
+
+
+ `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + highlighted: false, + }, +}; + +export const Highlighted: Story = { + args: { + highlighted: true, + }, +}; diff --git a/packages/lit-components/src/components/TableRow/TableRow.ts b/packages/lit-components/src/components/TableRow/TableRow.ts new file mode 100644 index 0000000000..7da2888c6c --- /dev/null +++ b/packages/lit-components/src/components/TableRow/TableRow.ts @@ -0,0 +1,32 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { TableRowView } from './TableRowView'; + +export interface TableRowProps { + highlighted: boolean; +} + +const parseBoolean = (value: unknown, fallback: boolean) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-table-row': HTMLElement; + } +} + +export const TableRow = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: TableRowProps = { + highlighted: parseBoolean(rawProps.highlighted, false), + }; + + return TableRowView(props); + }, + 'sql-table-row', + ['highlighted'] as const +); diff --git a/packages/lit-components/src/components/TableRow/TableRowView.ts b/packages/lit-components/src/components/TableRow/TableRowView.ts new file mode 100644 index 0000000000..0f217d8201 --- /dev/null +++ b/packages/lit-components/src/components/TableRow/TableRowView.ts @@ -0,0 +1,14 @@ +import { html } from 'lit'; +import { TableRowProps } from './TableRow'; + +export function TableRowView(props: TableRowProps) { + return html` + +
+ `; +} diff --git a/packages/lit-components/src/components/TableRow/index.ts b/packages/lit-components/src/components/TableRow/index.ts new file mode 100644 index 0000000000..68dce6f1e7 --- /dev/null +++ b/packages/lit-components/src/components/TableRow/index.ts @@ -0,0 +1 @@ +export * from './TableRow'; diff --git a/packages/lit-components/src/components/Tabs/Tabs.stories.ts b/packages/lit-components/src/components/Tabs/Tabs.stories.ts new file mode 100644 index 0000000000..2e88b7ed0c --- /dev/null +++ b/packages/lit-components/src/components/Tabs/Tabs.stories.ts @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/Tabs', + component: 'sql-tabs', + tags: ['autodocs'], + render: () => html` + + Overview content + Rewards content + + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithMultipleTabs: Story = { + render: () => html` + + Overview content + Rewards content + History content + + `, +}; diff --git a/packages/lit-components/src/components/Tabs/Tabs.ts b/packages/lit-components/src/components/Tabs/Tabs.ts new file mode 100644 index 0000000000..59b7bbe6c6 --- /dev/null +++ b/packages/lit-components/src/components/Tabs/Tabs.ts @@ -0,0 +1,17 @@ +import { useComponent } from '../../hooks/useComponent'; +import { TabsView } from './TabsView'; +import { useTabs } from './useTabs'; + +export interface TabsProps {} + +declare global { + interface HTMLElementTagNameMap { + 'sql-tabs': HTMLElement; + } +} + +export const Tabs = useComponent((_host) => { + const hookProps = useTabs(); + + return TabsView({ ...hookProps }); +}, 'sql-tabs'); diff --git a/packages/lit-components/src/components/Tabs/TabsView.ts b/packages/lit-components/src/components/Tabs/TabsView.ts new file mode 100644 index 0000000000..92bbb7d599 --- /dev/null +++ b/packages/lit-components/src/components/Tabs/TabsView.ts @@ -0,0 +1,14 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { useTabs } from './useTabs'; + +export function TabsView(_props: ReturnType) { + return html` + + ${UI.TabGroup({ children: html`` })} + `; +} diff --git a/packages/lit-components/src/components/Tabs/index.ts b/packages/lit-components/src/components/Tabs/index.ts new file mode 100644 index 0000000000..856dbbb347 --- /dev/null +++ b/packages/lit-components/src/components/Tabs/index.ts @@ -0,0 +1 @@ +export * from './Tabs'; diff --git a/packages/lit-components/src/components/Tabs/useTabs.test.ts b/packages/lit-components/src/components/Tabs/useTabs.test.ts new file mode 100644 index 0000000000..edbb6025f4 --- /dev/null +++ b/packages/lit-components/src/components/Tabs/useTabs.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useTabs } from './useTabs'; + +describe('useTabs', () => { + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + }); + + it('should return the initial active tab', () => { + const result = useTabs(); + + expect(result.activeTab).toBe(0); + expect(universalHooksMock.useState).toHaveBeenCalledWith(0); + }); + + it('should update the active tab on click', () => { + const result = useTabs(); + + result.onTabClick(3); + + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(3); + }); +}); diff --git a/packages/lit-components/src/components/Tabs/useTabs.ts b/packages/lit-components/src/components/Tabs/useTabs.ts new file mode 100644 index 0000000000..8c3f4259fc --- /dev/null +++ b/packages/lit-components/src/components/Tabs/useTabs.ts @@ -0,0 +1,11 @@ +import { useState } from '@saasquatch/universal-hooks'; + +export function useTabs() { + const [activeTab, setActiveTab] = useState(0); + + function onTabClick(index: number) { + setActiveTab(index); + } + + return { activeTab, onTabClick }; +} diff --git a/packages/lit-components/src/components/TaskCard/TaskCard.stories.ts b/packages/lit-components/src/components/TaskCard/TaskCard.stories.ts new file mode 100644 index 0000000000..3e01484b2e --- /dev/null +++ b/packages/lit-components/src/components/TaskCard/TaskCard.stories.ts @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import type { TaskCardProps } from './TaskCard'; + +const defaultArgs: TaskCardProps = { + rewardUnit: 'Points', + taskCardTitle: 'Invite a friend', + taskCardDescription: 'Share your referral link to start earning rewards.', + buttonText: 'Start', + showProgressBar: false, + progressCurrent: 0, + progressGoal: 1, + completedText: 'Completed!', + repeatable: false, + cardStatus: 'incomplete', +}; + +function normalizeArgs(args: Partial): TaskCardProps { + return { + ...defaultArgs, + ...args, + showProgressBar: args.showProgressBar ?? defaultArgs.showProgressBar, + progressCurrent: Number(args.progressCurrent ?? defaultArgs.progressCurrent), + progressGoal: Number(args.progressGoal ?? defaultArgs.progressGoal), + repeatable: args.repeatable ?? defaultArgs.repeatable, + cardStatus: args.cardStatus ?? defaultArgs.cardStatus, + }; +} + +const meta = { + title: 'Components/TaskCard', + component: 'sql-task-card', + tags: ['autodocs'], + argTypes: { + rewardAmount: { control: 'text' }, + rewardUnit: { control: 'text' }, + taskCardTitle: { control: 'text' }, + taskCardDescription: { control: 'text' }, + buttonText: { control: 'text' }, + buttonLink: { control: 'text' }, + showProgressBar: { control: 'boolean' }, + progressCurrent: { control: 'number' }, + progressGoal: { control: 'number' }, + completedText: { control: 'text' }, + repeatable: { control: 'boolean' }, + dateExpires: { control: 'text' }, + cardIcon: { control: 'text' }, + cardStatus: { control: 'select', options: ['incomplete', 'complete', 'expired'] }, + }, + render: (args: Partial) => { + const props = normalizeArgs(args); + + return html``; + }, +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithProgress: Story = { + args: { + showProgressBar: true, + progressCurrent: 2, + progressGoal: 5, + }, +}; + +export const Complete: Story = { + args: { + cardStatus: 'complete', + rewardAmount: '250', + }, +}; + +export const Expired: Story = { + args: { + cardStatus: 'expired', + dateExpires: 'Jan 31, 2026', + }, +}; + +export const WithIcon: Story = { + args: { + cardIcon: 'gift', + }, +}; + +export const WithReward: Story = { + args: { + rewardAmount: '500', + rewardUnit: 'Points', + }, +}; + +export const Repeatable: Story = { + args: { + repeatable: true, + dateExpires: 'Every Friday', + rewardAmount: '100', + }, +}; diff --git a/packages/lit-components/src/components/TaskCard/TaskCard.ts b/packages/lit-components/src/components/TaskCard/TaskCard.ts new file mode 100644 index 0000000000..06ca7487b1 --- /dev/null +++ b/packages/lit-components/src/components/TaskCard/TaskCard.ts @@ -0,0 +1,91 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { TaskCardView } from './TaskCardView'; +import { useDemoTaskCard, useTaskCard } from './useTaskCard'; + +export interface TaskCardProps { + rewardAmount?: string; + rewardUnit: string; + taskCardTitle?: string; + taskCardDescription?: string; + buttonText: string; + buttonLink?: string; + showProgressBar: boolean; + progressCurrent: number; + progressGoal: number; + completedText: string; + repeatable: boolean; + dateExpires?: string; + cardIcon?: string; + cardStatus: 'incomplete' | 'complete' | 'expired'; +} + +const parseBoolean = (value: unknown, fallback: boolean) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === '' || value === 'true'; + return fallback; +}; + +const parseNumber = (value: unknown, fallback: number) => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +}; + +function parseStatus(value: unknown): TaskCardProps['cardStatus'] { + return value === 'complete' || value === 'expired' ? value : 'incomplete'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-task-card': HTMLElement; + } +} + +export const TaskCard = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: TaskCardProps = { + rewardAmount: typeof rawProps.rewardAmount === 'string' ? rawProps.rewardAmount : undefined, + rewardUnit: typeof rawProps.rewardUnit === 'string' ? rawProps.rewardUnit : 'Points', + taskCardTitle: typeof rawProps.taskCardTitle === 'string' ? rawProps.taskCardTitle : undefined, + taskCardDescription: + typeof rawProps.taskCardDescription === 'string' ? rawProps.taskCardDescription : undefined, + buttonText: typeof rawProps.buttonText === 'string' ? rawProps.buttonText : 'Start', + buttonLink: typeof rawProps.buttonLink === 'string' ? rawProps.buttonLink : undefined, + showProgressBar: parseBoolean(rawProps.showProgressBar, false), + progressCurrent: parseNumber(rawProps.progressCurrent, 0), + progressGoal: parseNumber(rawProps.progressGoal, 1), + completedText: typeof rawProps.completedText === 'string' ? rawProps.completedText : 'Completed!', + repeatable: parseBoolean(rawProps.repeatable, false), + dateExpires: typeof rawProps.dateExpires === 'string' ? rawProps.dateExpires : undefined, + cardIcon: typeof rawProps.cardIcon === 'string' ? rawProps.cardIcon : undefined, + cardStatus: parseStatus(rawProps.cardStatus), + }; + + const hookProps = isDemo() ? useDemoTaskCard(props) : useTaskCard(props); + + return TaskCardView({ ...props, ...hookProps }); + }, + 'sql-task-card', + [ + 'reward-amount', + 'reward-unit', + 'task-card-title', + 'task-card-description', + 'button-text', + 'button-link', + 'show-progress-bar', + 'progress-current', + 'progress-goal', + 'completed-text', + 'repeatable', + 'date-expires', + 'card-icon', + 'card-status', + ] as const +); diff --git a/packages/lit-components/src/components/TaskCard/TaskCardView.ts b/packages/lit-components/src/components/TaskCard/TaskCardView.ts new file mode 100644 index 0000000000..60d5884220 --- /dev/null +++ b/packages/lit-components/src/components/TaskCard/TaskCardView.ts @@ -0,0 +1,123 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { TaskCardProps } from './TaskCard'; +import { TaskCardHookResult } from './useTaskCard'; + +export function TaskCardView(props: TaskCardProps & TaskCardHookResult) { + const classes = ['task-card']; + + if (props.isComplete) classes.push('complete'); + if (props.isExpired) classes.push('expired'); + + return html` + +
+ ${props.cardIcon ? html`${UI.Icon({ className: 'task-icon', name: props.cardIcon })}` : null} +
+
${props.taskCardTitle}
+ ${props.taskCardDescription + ? html`
${props.taskCardDescription}
` + : null} + ${props.showProgressBar && !props.isComplete + ? html` +
+
+
+ ` + : null} + ${props.isComplete ? html`${props.completedText}` : null} + ${props.repeatable || props.dateExpires + ? html` +
+ ${props.repeatable ? html`Repeatable` : null} + ${props.dateExpires ? html`Expires ${props.dateExpires}` : null} +
+ ` + : null} +
+
+ ${props.rewardAmount ? html`
${props.rewardAmount} ${props.rewardUnit}
` : null} + ${!props.isComplete && !props.isExpired + ? html`${UI.Button({ size: 'small', variant: 'primary', onClick: props.onClick, children: props.buttonText })}` + : null} +
+
+ `; +} diff --git a/packages/lit-components/src/components/TaskCard/index.ts b/packages/lit-components/src/components/TaskCard/index.ts new file mode 100644 index 0000000000..8f6e91df1f --- /dev/null +++ b/packages/lit-components/src/components/TaskCard/index.ts @@ -0,0 +1 @@ +export * from './TaskCard'; diff --git a/packages/lit-components/src/components/TaskCard/useTaskCard.test.ts b/packages/lit-components/src/components/TaskCard/useTaskCard.test.ts new file mode 100644 index 0000000000..9ae20a0670 --- /dev/null +++ b/packages/lit-components/src/components/TaskCard/useTaskCard.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useTaskCard } from './useTaskCard'; + +describe('useTaskCard', () => { + beforeEach(() => { + universalHooksMock.useState.mockClear(); + }); + + it('should calculate progress correctly', () => { + const result = useTaskCard({ progressCurrent: 5, progressGoal: 10, cardStatus: 'incomplete' } as any); + + expect(result.progress).toBe(50); + expect(result.isComplete).toBe(false); + }); + + it('should cap progress at 100%', () => { + const result = useTaskCard({ progressCurrent: 15, progressGoal: 10, cardStatus: 'incomplete' } as any); + + expect(result.progress).toBe(100); + }); + + it('should detect completed status', () => { + const result = useTaskCard({ progressCurrent: 10, progressGoal: 10, cardStatus: 'incomplete' } as any); + + expect(result.isComplete).toBe(true); + }); + + it('should detect complete from cardStatus prop', () => { + const result = useTaskCard({ progressCurrent: 0, progressGoal: 10, cardStatus: 'complete' } as any); + + expect(result.isComplete).toBe(true); + }); + + it('should detect expired status', () => { + const result = useTaskCard({ progressCurrent: 0, progressGoal: 10, cardStatus: 'expired' } as any); + + expect(result.isExpired).toBe(true); + }); + + it('should have onClick handler', () => { + const result = useTaskCard({ + progressCurrent: 0, + progressGoal: 10, + cardStatus: 'incomplete', + buttonLink: 'https://example.com', + } as any); + + expect(typeof result.onClick).toBe('function'); + }); +}); diff --git a/packages/lit-components/src/components/TaskCard/useTaskCard.ts b/packages/lit-components/src/components/TaskCard/useTaskCard.ts new file mode 100644 index 0000000000..92bf3c43bc --- /dev/null +++ b/packages/lit-components/src/components/TaskCard/useTaskCard.ts @@ -0,0 +1,52 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { TaskCardProps } from './TaskCard'; + +export interface TaskCardHookResult { + progress: number; + isComplete: boolean; + isExpired: boolean; + onClick: () => void; +} + +export function useTaskCard(props: TaskCardProps): TaskCardHookResult { + const [, setLastClickedAt] = useState(null); + const current = Number(props.progressCurrent) || 0; + const goal = Math.max(Number(props.progressGoal) || 1, 1); + const progress = Math.min((current / goal) * 100, 100); + const isComplete = props.cardStatus === 'complete' || current >= goal; + const isExpired = props.cardStatus === 'expired'; + + function onClick() { + setLastClickedAt(Date.now()); + + if (props.buttonLink) { + window.open(props.buttonLink, '_blank'); + } + + const event = new CustomEvent('sq:task-click', { + bubbles: true, + composed: true, + detail: { taskTitle: props.taskCardTitle }, + }); + + document.dispatchEvent(event); + } + + return { progress, isComplete, isExpired, onClick }; +} + +export function useDemoTaskCard(props: TaskCardProps): TaskCardHookResult { + const current = Number(props.progressCurrent) || 3; + const goal = Math.max(Number(props.progressGoal) || 5, 1); + const progress = Math.min((current / goal) * 100, 100); + const isComplete = props.cardStatus === 'complete' || current >= goal; + const isExpired = props.cardStatus === 'expired'; + + function onClick() { + if (props.buttonLink) { + window.open(props.buttonLink, '_blank'); + } + } + + return { progress, isComplete, isExpired, onClick }; +} diff --git a/packages/lit-components/src/components/TaxAndCash/TaxAndCash.stories.ts b/packages/lit-components/src/components/TaxAndCash/TaxAndCash.stories.ts new file mode 100644 index 0000000000..d4cdc2a323 --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCash/TaxAndCash.stories.ts @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { TaxAndCashView } from './TaxAndCashView'; + +const meta: Meta = { + title: 'Components/TaxAndCash', + component: 'sql-tax-and-cash', + tags: ['autodocs'], + argTypes: { + 'program-id': { control: 'text' }, + step: { control: 'select', options: ['dashboard', 'user-info', 'tax-form', 'banking-info', 'docusign'] }, + }, + render: (args) => html` + + + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + programId: 'demo-program', + step: 'dashboard' as const, + currentStep: 'dashboard', + setCurrentStep: () => undefined, + loading: false, + status: 'NOT_STARTED', + taxFormStatus: 'NOT_STARTED', + bankingInfoStatus: 'NOT_STARTED', + payoutStatus: 'NOT_STARTED', +}; + +export const Default: Story = {}; + +export const Loading: Story = { + render: () => + TaxAndCashView({ + ...baseProps, + loading: true, + }), +}; + +export const Dashboard: Story = { + render: () => html` + +
+ Dashboard content slot +
+
+ `, +}; diff --git a/packages/lit-components/src/components/TaxAndCash/TaxAndCash.ts b/packages/lit-components/src/components/TaxAndCash/TaxAndCash.ts new file mode 100644 index 0000000000..4f1d180319 --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCash/TaxAndCash.ts @@ -0,0 +1,44 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { TaxAndCashView } from './TaxAndCashView'; +import { useDemoTaxAndCash, useTaxAndCash } from './useTaxAndCash'; + +export interface TaxAndCashProps { + programId?: string; + step: 'dashboard' | 'user-info' | 'tax-form' | 'banking-info' | 'docusign'; +} + +const parseStep = (value: unknown): TaxAndCashProps['step'] => { + switch (value) { + case 'user-info': + case 'tax-form': + case 'banking-info': + case 'docusign': + return value; + default: + return 'dashboard'; + } +}; + +declare global { + interface HTMLElementTagNameMap { + 'sql-tax-and-cash': HTMLElement; + } +} + +export const TaxAndCash = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: TaxAndCashProps = { + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + step: parseStep(rawProps.step), + }; + + const hookProps = isDemo() ? useDemoTaxAndCash(props) : useTaxAndCash(props); + + return TaxAndCashView({ ...props, ...hookProps }); + }, + 'sql-tax-and-cash', + ['program-id', 'step'] as const +); diff --git a/packages/lit-components/src/components/TaxAndCash/TaxAndCashView.ts b/packages/lit-components/src/components/TaxAndCash/TaxAndCashView.ts new file mode 100644 index 0000000000..eaf4031543 --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCash/TaxAndCashView.ts @@ -0,0 +1,24 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { TaxAndCashProps } from './TaxAndCash'; +import { useTaxAndCash } from './useTaxAndCash'; + +export function TaxAndCashView(props: TaxAndCashProps & ReturnType) { + return html` + +
+ ${props.loading + ? html`${UI.Spinner({ style: 'font-size: 2rem;' })}` + : html``} +
+ `; +} diff --git a/packages/lit-components/src/components/TaxAndCash/index.ts b/packages/lit-components/src/components/TaxAndCash/index.ts new file mode 100644 index 0000000000..700b3ff8a1 --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCash/index.ts @@ -0,0 +1 @@ +export * from './TaxAndCash'; diff --git a/packages/lit-components/src/components/TaxAndCash/useTaxAndCash.test.ts b/packages/lit-components/src/components/TaxAndCash/useTaxAndCash.test.ts new file mode 100644 index 0000000000..efdd7bf537 --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCash/useTaxAndCash.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { + viewer: { + taxHandling: { + status: 'IN_PROGRESS', + publisher: { + taxFormStatus: 'COMPLETE', + bankingInfoStatus: 'PENDING', + payoutStatus: 'APPROVED', + }, + }, + }, + }, + loading: false, + })), +})); + +const universalHooksMock = vi.hoisted(() => ({ + useState: vi.fn((initial: unknown) => [initial, vi.fn()] as const), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useTaxAndCash } from './useTaxAndCash'; + +describe('useTaxAndCash', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + viewer: { + taxHandling: { + status: 'IN_PROGRESS', + publisher: { + taxFormStatus: 'COMPLETE', + bankingInfoStatus: 'PENDING', + payoutStatus: 'APPROVED', + }, + }, + }, + }, + loading: false, + }); + }); + + it('returns statuses from query data', () => { + const result = useTaxAndCash({} as any); + + expect(result.status).toBe('IN_PROGRESS'); + expect(result.taxFormStatus).toBe('COMPLETE'); + expect(result.bankingInfoStatus).toBe('PENDING'); + expect(result.payoutStatus).toBe('APPROVED'); + }); + + it('returns NOT_STARTED defaults when no data exists', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: { viewer: {} }, loading: false }); + + const result = useTaxAndCash({} as any); + + expect(result.status).toBe('NOT_STARTED'); + expect(result.taxFormStatus).toBe('NOT_STARTED'); + expect(result.bankingInfoStatus).toBe('NOT_STARTED'); + expect(result.payoutStatus).toBe('NOT_STARTED'); + }); + + it('uses props.step or dashboard as the initial current step', () => { + const withProp = useTaxAndCash({ step: 'banking-info' } as any); + const withoutProp = useTaxAndCash({} as any); + + expect(withProp.currentStep).toBe('banking-info'); + expect(withoutProp.currentStep).toBe('dashboard'); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useTaxAndCash({} as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/TaxAndCash/useTaxAndCash.ts b/packages/lit-components/src/components/TaxAndCash/useTaxAndCash.ts new file mode 100644 index 0000000000..ef1fe43a5d --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCash/useTaxAndCash.ts @@ -0,0 +1,57 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { gql } from 'graphql-request'; +import { TaxAndCashProps } from './TaxAndCash'; + +const TAX_STATUS_QUERY = gql` + query getTaxStatus($programId: ID) { + viewer { + ... on User { + taxHandling(programId: $programId) { + status + publisher { + taxFormStatus + indirectTaxFormStatus + payoutStatus + bankingInfoStatus + } + } + } + } + } +`; + +export function useTaxAndCash(props: TaxAndCashProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const [currentStep, setCurrentStep] = useState(props.step || 'dashboard'); + + const { data, loading } = useQuery(TAX_STATUS_QUERY, { programId }, !user?.jwt); + + const taxHandling = data?.viewer?.taxHandling; + const publisher = taxHandling?.publisher; + + return { + currentStep, + setCurrentStep, + loading, + status: taxHandling?.status || 'NOT_STARTED', + taxFormStatus: publisher?.taxFormStatus || 'NOT_STARTED', + bankingInfoStatus: publisher?.bankingInfoStatus || 'NOT_STARTED', + payoutStatus: publisher?.payoutStatus || 'NOT_STARTED', + }; +} + +export function useDemoTaxAndCash(props: TaxAndCashProps): ReturnType { + const [currentStep, setCurrentStep] = useState(props.step || 'dashboard'); + + return { + currentStep, + setCurrentStep, + loading: false, + status: 'IN_PROGRESS', + taxFormStatus: 'PENDING', + bankingInfoStatus: 'NOT_STARTED', + payoutStatus: 'NOT_STARTED', + }; +} diff --git a/packages/lit-components/src/components/TaxAndCashDashboard/TaxAndCashDashboard.stories.ts b/packages/lit-components/src/components/TaxAndCashDashboard/TaxAndCashDashboard.stories.ts new file mode 100644 index 0000000000..511ac90404 --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCashDashboard/TaxAndCashDashboard.stories.ts @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { TaxAndCashDashboardView } from './TaxAndCashDashboardView'; + +const meta: Meta = { + title: 'Components/TaxAndCashDashboard', + component: 'sql-tax-and-cash-dashboard', + tags: ['autodocs'], + argTypes: { + 'user-info-label': { control: 'text' }, + 'tax-form-label': { control: 'text' }, + 'banking-info-label': { control: 'text' }, + 'payout-label': { control: 'text' }, + 'complete-text': { control: 'text' }, + 'incomplete-text': { control: 'text' }, + 'pending-text': { control: 'text' }, + 'program-id': { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +const getStatusText = (status: string) => { + switch (status) { + case 'COMPLETE': + return 'Complete'; + case 'PENDING': + case 'PENDING_REVIEW': + return 'Pending'; + default: + return 'Incomplete'; + } +}; + +const getStatusVariant = (status: string) => { + switch (status) { + case 'COMPLETE': + return 'success'; + case 'PENDING': + case 'PENDING_REVIEW': + return 'warning'; + default: + return 'neutral'; + } +}; + +const baseProps = { + userInfoLabel: 'Personal Information', + taxFormLabel: 'Tax Information', + bankingInfoLabel: 'Banking Information', + payoutLabel: 'Payout', + completeText: 'Complete', + incompleteText: 'Incomplete', + pendingText: 'Pending', + programId: 'demo-program', + loading: false, + getStatusText, + getStatusVariant, + steps: [ + { label: 'Personal Information', status: 'NOT_STARTED', key: 'user-info' }, + { label: 'Tax Information', status: 'NOT_STARTED', key: 'tax-form' }, + { label: 'Banking Information', status: 'NOT_STARTED', key: 'banking-info' }, + { label: 'Payout', status: 'NOT_STARTED', key: 'payout' }, + ], +}; + +export const Default: Story = { + render: () => TaxAndCashDashboardView(baseProps), +}; + +export const Loading: Story = { + render: () => + TaxAndCashDashboardView({ + ...baseProps, + loading: true, + }), +}; + +export const AllComplete: Story = { + render: () => + TaxAndCashDashboardView({ + ...baseProps, + steps: baseProps.steps.map((step) => ({ ...step, status: 'COMPLETE' })), + }), +}; + +export const Mixed: Story = { + render: () => + TaxAndCashDashboardView({ + ...baseProps, + steps: [ + { label: 'Personal Information', status: 'COMPLETE', key: 'user-info' }, + { label: 'Tax Information', status: 'PENDING_REVIEW', key: 'tax-form' }, + { label: 'Banking Information', status: 'NOT_STARTED', key: 'banking-info' }, + { label: 'Payout', status: 'PENDING', key: 'payout' }, + ], + }), +}; diff --git a/packages/lit-components/src/components/TaxAndCashDashboard/TaxAndCashDashboard.ts b/packages/lit-components/src/components/TaxAndCashDashboard/TaxAndCashDashboard.ts new file mode 100644 index 0000000000..15585e3224 --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCashDashboard/TaxAndCashDashboard.ts @@ -0,0 +1,54 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { TaxAndCashDashboardView } from './TaxAndCashDashboardView'; +import { useDemoTaxAndCashDashboard, useTaxAndCashDashboard } from './useTaxAndCashDashboard'; + +export interface TaxAndCashDashboardProps { + userInfoLabel: string; + taxFormLabel: string; + bankingInfoLabel: string; + payoutLabel: string; + completeText: string; + incompleteText: string; + pendingText: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-tax-and-cash-dashboard': HTMLElement; + } +} + +export const TaxAndCashDashboard = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: TaxAndCashDashboardProps = { + userInfoLabel: typeof rawProps.userInfoLabel === 'string' ? rawProps.userInfoLabel : 'Personal Information', + taxFormLabel: typeof rawProps.taxFormLabel === 'string' ? rawProps.taxFormLabel : 'Tax Information', + bankingInfoLabel: + typeof rawProps.bankingInfoLabel === 'string' ? rawProps.bankingInfoLabel : 'Banking Information', + payoutLabel: typeof rawProps.payoutLabel === 'string' ? rawProps.payoutLabel : 'Payout', + completeText: typeof rawProps.completeText === 'string' ? rawProps.completeText : 'Complete', + incompleteText: typeof rawProps.incompleteText === 'string' ? rawProps.incompleteText : 'Incomplete', + pendingText: typeof rawProps.pendingText === 'string' ? rawProps.pendingText : 'Pending', + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + }; + + const hookProps = isDemo() ? useDemoTaxAndCashDashboard(props) : useTaxAndCashDashboard(props); + + return TaxAndCashDashboardView({ ...props, ...hookProps }); + }, + 'sql-tax-and-cash-dashboard', + [ + 'user-info-label', + 'tax-form-label', + 'banking-info-label', + 'payout-label', + 'complete-text', + 'incomplete-text', + 'pending-text', + 'program-id', + ] as const +); diff --git a/packages/lit-components/src/components/TaxAndCashDashboard/TaxAndCashDashboardView.ts b/packages/lit-components/src/components/TaxAndCashDashboard/TaxAndCashDashboardView.ts new file mode 100644 index 0000000000..db3909b4bb --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCashDashboard/TaxAndCashDashboardView.ts @@ -0,0 +1,72 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { TaxAndCashDashboardProps } from './TaxAndCashDashboard'; +import { useTaxAndCashDashboard } from './useTaxAndCashDashboard'; + +export function TaxAndCashDashboardView( + props: TaxAndCashDashboardProps & ReturnType +) { + return html` + +
+ ${props.loading + ? html`${UI.Spinner({})}` + : props.steps.map( + (step) => html` +
+ ${step.label} +
+ ${UI.Badge({ variant: props.getStatusVariant(step.status), children: props.getStatusText(step.status) })} + ${UI.Icon({ name: 'chevron-right' })} +
+
+ ` + )} +
+ `; +} diff --git a/packages/lit-components/src/components/TaxAndCashDashboard/index.ts b/packages/lit-components/src/components/TaxAndCashDashboard/index.ts new file mode 100644 index 0000000000..4f9fa32f51 --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCashDashboard/index.ts @@ -0,0 +1 @@ +export * from './TaxAndCashDashboard'; diff --git a/packages/lit-components/src/components/TaxAndCashDashboard/useTaxAndCashDashboard.test.ts b/packages/lit-components/src/components/TaxAndCashDashboard/useTaxAndCashDashboard.test.ts new file mode 100644 index 0000000000..2f8d1e386a --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCashDashboard/useTaxAndCashDashboard.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ + data: { + viewer: { + taxHandling: { + publisher: { + userInfoStatus: 'COMPLETE', + taxFormStatus: 'PENDING', + bankingInfoStatus: 'PENDING_REVIEW', + payoutStatus: 'NOT_STARTED', + }, + }, + }, + }, + loading: false, + })), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { useTaxAndCashDashboard } from './useTaxAndCashDashboard'; + +describe('useTaxAndCashDashboard', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { + viewer: { + taxHandling: { + publisher: { + userInfoStatus: 'COMPLETE', + taxFormStatus: 'PENDING', + bankingInfoStatus: 'PENDING_REVIEW', + payoutStatus: 'NOT_STARTED', + }, + }, + }, + }, + loading: false, + }); + }); + + it('returns steps with the expected labels and statuses', () => { + const result = useTaxAndCashDashboard({} as any); + + expect(result.steps).toEqual([ + { label: 'Personal Information', status: 'COMPLETE', key: 'user-info' }, + { label: 'Tax Information', status: 'PENDING', key: 'tax-form' }, + { label: 'Banking Information', status: 'PENDING_REVIEW', key: 'banking-info' }, + { label: 'Payout', status: 'NOT_STARTED', key: 'payout' }, + ]); + }); + + it('maps status text correctly', () => { + const result = useTaxAndCashDashboard({} as any); + + expect(result.getStatusText('COMPLETE')).toBe('Complete'); + expect(result.getStatusText('PENDING')).toBe('Pending'); + expect(result.getStatusText('NOT_STARTED')).toBe('Incomplete'); + }); + + it('maps status variants correctly', () => { + const result = useTaxAndCashDashboard({} as any); + + expect(result.getStatusVariant('COMPLETE')).toBe('success'); + expect(result.getStatusVariant('PENDING_REVIEW')).toBe('warning'); + expect(result.getStatusVariant('NOT_STARTED')).toBe('neutral'); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useTaxAndCashDashboard({} as any); + + expect(result.loading).toBe(true); + }); + + it('uses label props when provided', () => { + const result = useTaxAndCashDashboard({ + userInfoLabel: 'Profile', + taxFormLabel: 'Tax Docs', + bankingInfoLabel: 'Bank Account', + payoutLabel: 'Cash Out', + } as any); + + expect(result.steps.map((step) => step.label)).toEqual(['Profile', 'Tax Docs', 'Bank Account', 'Cash Out']); + }); +}); diff --git a/packages/lit-components/src/components/TaxAndCashDashboard/useTaxAndCashDashboard.ts b/packages/lit-components/src/components/TaxAndCashDashboard/useTaxAndCashDashboard.ts new file mode 100644 index 0000000000..fac6b37a9f --- /dev/null +++ b/packages/lit-components/src/components/TaxAndCashDashboard/useTaxAndCashDashboard.ts @@ -0,0 +1,131 @@ +import { useProgramId, useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { gql } from 'graphql-request'; +import { TaxAndCashDashboardProps } from './TaxAndCashDashboard'; + +const DASHBOARD_QUERY = gql` + query getTaxDashboard($programId: ID) { + viewer { + ... on User { + taxHandling(programId: $programId) { + publisher { + userInfoStatus + taxFormStatus + indirectTaxFormStatus + bankingInfoStatus + payoutStatus + } + } + } + } + } +`; + +export function useTaxAndCashDashboard(props: TaxAndCashDashboardProps) { + const programId = useProgramId() || props.programId; + const user = useUserIdentity(); + const { data, loading } = useQuery(DASHBOARD_QUERY, { programId }, !user?.jwt); + + const publisher = data?.viewer?.taxHandling?.publisher; + + const steps = [ + { + label: props.userInfoLabel || 'Personal Information', + status: publisher?.userInfoStatus || 'NOT_STARTED', + key: 'user-info', + }, + { + label: props.taxFormLabel || 'Tax Information', + status: publisher?.taxFormStatus || 'NOT_STARTED', + key: 'tax-form', + }, + { + label: props.bankingInfoLabel || 'Banking Information', + status: publisher?.bankingInfoStatus || 'NOT_STARTED', + key: 'banking-info', + }, + { + label: props.payoutLabel || 'Payout', + status: publisher?.payoutStatus || 'NOT_STARTED', + key: 'payout', + }, + ]; + + function getStatusText(status: string) { + switch (status) { + case 'COMPLETE': + return props.completeText || 'Complete'; + case 'PENDING': + case 'PENDING_REVIEW': + return props.pendingText || 'Pending'; + default: + return props.incompleteText || 'Incomplete'; + } + } + + function getStatusVariant(status: string) { + switch (status) { + case 'COMPLETE': + return 'success'; + case 'PENDING': + case 'PENDING_REVIEW': + return 'warning'; + default: + return 'neutral'; + } + } + + return { steps, loading, getStatusText, getStatusVariant }; +} + +export function useDemoTaxAndCashDashboard( + props: TaxAndCashDashboardProps +): ReturnType { + const steps = [ + { + label: props.userInfoLabel || 'Personal Information', + status: 'COMPLETE', + key: 'user-info', + }, + { + label: props.taxFormLabel || 'Tax Information', + status: 'PENDING', + key: 'tax-form', + }, + { + label: props.bankingInfoLabel || 'Banking Information', + status: 'NOT_STARTED', + key: 'banking-info', + }, + { + label: props.payoutLabel || 'Payout', + status: 'NOT_STARTED', + key: 'payout', + }, + ]; + + function getStatusText(status: string) { + switch (status) { + case 'COMPLETE': + return props.completeText || 'Complete'; + case 'PENDING': + case 'PENDING_REVIEW': + return props.pendingText || 'Pending'; + default: + return props.incompleteText || 'Incomplete'; + } + } + + function getStatusVariant(status: string) { + switch (status) { + case 'COMPLETE': + return 'success'; + case 'PENDING': + case 'PENDING_REVIEW': + return 'warning'; + default: + return 'neutral'; + } + } + + return { steps, loading: false, getStatusText, getStatusVariant }; +} diff --git a/packages/lit-components/src/components/Text/Text.stories.ts b/packages/lit-components/src/components/Text/Text.stories.ts new file mode 100644 index 0000000000..d8b4b66231 --- /dev/null +++ b/packages/lit-components/src/components/Text/Text.stories.ts @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/Text', + component: 'sql-text', + tags: ['autodocs'], + argTypes: { + 'font-size': { control: 'number' }, + 'text-color': { control: 'color' }, + }, + render: (args) => { + return html` +

${args.content || 'This is some text content'}

+
`; + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * Default text component with paragraph + */ +export const Default: Story = { + args: { + content: 'This is a paragraph of text.', + }, +}; + +/** + * Heading 1 + */ +export const Heading1: Story = { + render: (args) => html` +

This is a Heading 1

+
`, +}; + +/** + * Heading 2 + */ +export const Heading2: Story = { + render: (args) => html` +

This is a Heading 2

+
`, +}; + +/** + * Heading 3 + */ +export const Heading3: Story = { + render: (args) => html` +

This is a Heading 3

+
`, +}; + +/** + * Heading 4 + */ +export const Heading4: Story = { + render: (args) => html` +

This is a Heading 4

+
`, +}; + +/** + * Custom font size + */ +export const CustomFontSize: Story = { + args: { + 'font-size': 24, + content: 'This text has a custom 24px font size.', + }, +}; + +/** + * Custom text color + */ +export const CustomColor: Story = { + args: { + 'text-color': '#4169e1', + content: 'This text has a custom blue color.', + }, +}; + +/** + * Multiple elements + */ +export const MultipleElements: Story = { + render: (args) => html` +

Welcome to our program

+

Refer your friends and earn rewards!

+
`, +}; + +/** + * Subscript text + */ +export const SubscriptText: Story = { + render: () => html` + This is smaller subscript text + `, +}; diff --git a/packages/lit-components/src/components/Text/Text.ts b/packages/lit-components/src/components/Text/Text.ts new file mode 100644 index 0000000000..e6b924fb45 --- /dev/null +++ b/packages/lit-components/src/components/Text/Text.ts @@ -0,0 +1,107 @@ +import { html } from 'lit'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; + +export interface TextProps { + /** + * Font size in pixels + * + * @uiName Font Size + * @uiType number + */ + fontSize?: number; + + /** + * @uiName Text Color + * @uiWidget color + * @format color + */ + textColor?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-text': HTMLElement; + } +} + +/** + * @uiName Text + * @validParents ["sqm-portal-container","div","sqm-divided-layout","sqm-brand","template","sqm-hero","sqm-big-stat","span","sqm-text-span","sqb-program-section","sqb-conditional-section", "sqm-titled-section"] + * @slots [{"name":"", "title":"Text"}] + * @slotEditor richText + * @canvasRenderer always-replace + */ +export const Text = useComponent( + (host) => { + const props: TextProps = { + ...getProps(host), + }; + + const fontSize = props.fontSize ? Number(props.fontSize) : undefined; + const textColor = props.textColor || 'var(--sqm-text)'; + + // NOTE: This component uses light DOM (no shadow DOM) in mint-components. + // In lit-components we use shadow DOM but replicate the styling behavior + // by targeting slotted elements. + const styles = ` + :host { + display: block; + color: ${textColor}; + } + + ::slotted(h1) { + font-size: ${fontSize ? `${fontSize}px` : 'var(--sl-font-size-xxx-large)'}; + font-weight: var(--sl-font-weight-semibold); + color: ${textColor}; + margin: 0; + } + + ::slotted(h2) { + font-size: ${fontSize ? `${fontSize}px` : 'var(--sl-font-size-xx-large)'}; + font-weight: var(--sl-font-weight-semibold); + color: ${textColor}; + margin: 0; + } + + ::slotted(h3) { + font-size: ${fontSize ? `${fontSize}px` : 'var(--sl-font-size-x-large)'}; + font-weight: var(--sl-font-weight-semibold); + color: ${textColor}; + margin: 0; + } + + ::slotted(h4) { + font-size: ${fontSize ? `${fontSize}px` : 'var(--sl-font-size-large)'}; + font-weight: var(--sl-font-weight-bold); + color: ${textColor}; + margin: 0; + } + + ::slotted(p) { + font-size: ${fontSize ? `${fontSize}px` : 'var(--sl-font-size-medium)'}; + font-weight: var(--sl-font-weight-normal); + color: ${textColor}; + margin: 0; + } + + ::slotted(sub) { + font-size: ${fontSize ? `${fontSize}px` : 'var(--sl-font-size-small)'}; + font-weight: var(--sl-font-weight-normal); + color: ${textColor}; + margin: 0; + } + `; + + return html` + +
+ +
+ `; + }, + 'sql-text', + ['font-size', 'text-color'] as const +); diff --git a/packages/lit-components/src/components/Text/index.ts b/packages/lit-components/src/components/Text/index.ts new file mode 100644 index 0000000000..3db2d7e7be --- /dev/null +++ b/packages/lit-components/src/components/Text/index.ts @@ -0,0 +1 @@ +export { Text } from './Text'; diff --git a/packages/lit-components/src/components/TextSpan/TextSpan.stories.ts b/packages/lit-components/src/components/TextSpan/TextSpan.stories.ts new file mode 100644 index 0000000000..e1c261de13 --- /dev/null +++ b/packages/lit-components/src/components/TextSpan/TextSpan.stories.ts @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta: Meta = { + title: 'Components/TextSpan', + component: 'sql-text-span', + tags: ['autodocs'], + argTypes: { + 'font-size': { control: 'number' }, + 'text-color': { control: 'color' }, + 'font-weight': { control: 'number' }, + }, + render: (args) => + html` + ${args.content || 'Inline text span'} + `, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: 'Inline text span', + }, +}; + +export const CustomSize: Story = { + args: { + 'font-size': 20, + content: 'Larger inline text', + }, +}; + +export const CustomColor: Story = { + args: { + 'text-color': '#2563eb', + content: 'Colored inline text', + }, +}; + +export const Bold: Story = { + args: { + 'font-weight': 700, + content: 'Bold inline text', + }, +}; diff --git a/packages/lit-components/src/components/TextSpan/TextSpan.ts b/packages/lit-components/src/components/TextSpan/TextSpan.ts new file mode 100644 index 0000000000..0d170aff8c --- /dev/null +++ b/packages/lit-components/src/components/TextSpan/TextSpan.ts @@ -0,0 +1,30 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { TextSpanView } from './TextSpanView'; + +export interface TextSpanProps { + fontSize?: number; + textColor?: string; + fontWeight?: number; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-text-span': HTMLElement; + } +} + +export const TextSpan = useComponent( + (host) => { + const rawProps = getProps(host) as Partial; + const props: TextSpanProps = { + fontSize: rawProps.fontSize ? Number(rawProps.fontSize) : undefined, + textColor: rawProps.textColor, + fontWeight: rawProps.fontWeight ? Number(rawProps.fontWeight) : undefined, + }; + + return TextSpanView(props); + }, + 'sql-text-span', + ['font-size', 'text-color', 'font-weight'] as const +); diff --git a/packages/lit-components/src/components/TextSpan/TextSpanView.ts b/packages/lit-components/src/components/TextSpan/TextSpanView.ts new file mode 100644 index 0000000000..8484d41286 --- /dev/null +++ b/packages/lit-components/src/components/TextSpan/TextSpanView.ts @@ -0,0 +1,19 @@ +import { html } from 'lit'; +import { TextSpanProps } from './TextSpan'; + +export function TextSpanView(props: TextSpanProps) { + return html` + + + `; +} diff --git a/packages/lit-components/src/components/TextSpan/index.ts b/packages/lit-components/src/components/TextSpan/index.ts new file mode 100644 index 0000000000..e47937c49d --- /dev/null +++ b/packages/lit-components/src/components/TextSpan/index.ts @@ -0,0 +1 @@ +export * from './TextSpan'; diff --git a/packages/lit-components/src/components/Timeline/Timeline.stories.ts b/packages/lit-components/src/components/Timeline/Timeline.stories.ts new file mode 100644 index 0000000000..d3f2653491 --- /dev/null +++ b/packages/lit-components/src/components/Timeline/Timeline.stories.ts @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const meta = { + title: 'Components/Timeline', + component: 'sql-timeline', + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => html` +
Add timeline entries here
+
`, +}; + +export const WithEntries: Story = { + render: () => html` + + + + `, +}; diff --git a/packages/lit-components/src/components/Timeline/Timeline.ts b/packages/lit-components/src/components/Timeline/Timeline.ts new file mode 100644 index 0000000000..776fa6eb6d --- /dev/null +++ b/packages/lit-components/src/components/Timeline/Timeline.ts @@ -0,0 +1,10 @@ +import { useComponent } from '../../hooks/useComponent'; +import { TimelineView } from './TimelineView'; + +declare global { + interface HTMLElementTagNameMap { + 'sql-timeline': HTMLElement; + } +} + +export const Timeline = useComponent(() => TimelineView(), 'sql-timeline'); diff --git a/packages/lit-components/src/components/Timeline/TimelineView.ts b/packages/lit-components/src/components/Timeline/TimelineView.ts new file mode 100644 index 0000000000..f6faf83754 --- /dev/null +++ b/packages/lit-components/src/components/Timeline/TimelineView.ts @@ -0,0 +1,29 @@ +import { html } from 'lit'; + +export function TimelineView() { + return html` + +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/Timeline/index.ts b/packages/lit-components/src/components/Timeline/index.ts new file mode 100644 index 0000000000..83f79cad5c --- /dev/null +++ b/packages/lit-components/src/components/Timeline/index.ts @@ -0,0 +1 @@ +export * from './Timeline'; diff --git a/packages/lit-components/src/components/TimelineEntry/TimelineEntry.stories.ts b/packages/lit-components/src/components/TimelineEntry/TimelineEntry.stories.ts new file mode 100644 index 0000000000..3cf01aeb65 --- /dev/null +++ b/packages/lit-components/src/components/TimelineEntry/TimelineEntry.stories.ts @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import type { TimelineEntryProps } from './TimelineEntry'; + +const defaultArgs: TimelineEntryProps = { + entryLabel: 'Referral started', + entryDescription: 'Your friend signed up using your referral link.', + entryIcon: 'circle-fill', + entryColor: 'var(--sl-color-primary-600)', + status: 'pending', +}; + +function normalizeArgs(args: Partial): TimelineEntryProps { + return { + ...defaultArgs, + ...args, + entryIcon: args.entryIcon ?? defaultArgs.entryIcon, + entryColor: args.entryColor ?? defaultArgs.entryColor, + status: args.status ?? defaultArgs.status, + }; +} + +const meta = { + title: 'Components/TimelineEntry', + component: 'sql-timeline-entry', + tags: ['autodocs'], + argTypes: { + entryLabel: { control: 'text' }, + entryDescription: { control: 'text' }, + entryDate: { control: 'text' }, + entryIcon: { control: 'text' }, + entryColor: { control: 'color' }, + status: { control: 'select', options: ['complete', 'active', 'pending'] }, + }, + render: (args: Partial) => { + const props = normalizeArgs(args); + return html``; + }, +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Complete: Story = { + args: { + status: 'complete', + }, +}; + +export const Active: Story = { + args: { + status: 'active', + entryIcon: 'star-fill', + }, +}; + +export const Pending: Story = {}; + +export const WithDescription: Story = { + args: { + entryDescription: 'Share your link with a friend to begin this step.', + }, +}; + +export const WithDate: Story = { + args: { + entryDate: 'Jan 20, 2026', + }, +}; + +export const InTimeline: Story = { + render: () => html` + + + `, +}; diff --git a/packages/lit-components/src/components/TimelineEntry/TimelineEntry.ts b/packages/lit-components/src/components/TimelineEntry/TimelineEntry.ts new file mode 100644 index 0000000000..ad26fb4252 --- /dev/null +++ b/packages/lit-components/src/components/TimelineEntry/TimelineEntry.ts @@ -0,0 +1,44 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { TimelineEntryView } from './TimelineEntryView'; + +export interface TimelineEntryProps { + entryLabel?: string; + entryDescription?: string; + entryDate?: string; + entryIcon: string; + entryColor: string; + status: 'complete' | 'active' | 'pending'; +} + +function parseStatus(value: unknown): TimelineEntryProps['status'] { + return value === 'complete' || value === 'active' ? value : 'pending'; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-timeline-entry': HTMLElement; + } +} + +export const TimelineEntry = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: TimelineEntryProps = { + entryLabel: typeof rawProps.entryLabel === 'string' ? rawProps.entryLabel : undefined, + entryDescription: + typeof rawProps.entryDescription === 'string' ? rawProps.entryDescription : undefined, + entryDate: typeof rawProps.entryDate === 'string' ? rawProps.entryDate : undefined, + entryIcon: typeof rawProps.entryIcon === 'string' ? rawProps.entryIcon : 'circle-fill', + entryColor: + typeof rawProps.entryColor === 'string' + ? rawProps.entryColor + : 'var(--sl-color-primary-600)', + status: parseStatus(rawProps.status), + }; + + return TimelineEntryView(props); + }, + 'sql-timeline-entry', + ['entry-label', 'entry-description', 'entry-date', 'entry-icon', 'entry-color', 'status'] as const +); diff --git a/packages/lit-components/src/components/TimelineEntry/TimelineEntryView.ts b/packages/lit-components/src/components/TimelineEntry/TimelineEntryView.ts new file mode 100644 index 0000000000..3b77bc7307 --- /dev/null +++ b/packages/lit-components/src/components/TimelineEntry/TimelineEntryView.ts @@ -0,0 +1,78 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { TimelineEntryProps } from './TimelineEntry'; + +export function TimelineEntryView(props: TimelineEntryProps) { + const markerColor = + props.status === 'complete' + ? 'var(--sl-color-success-500)' + : props.status === 'active' + ? props.entryColor + : 'var(--sl-color-neutral-300)'; + + return html` + +
+
${UI.Icon({ name: props.entryIcon })}
+
+ + ${props.entryDescription + ? html`
${props.entryDescription}
` + : null} + ${props.entryDate ? html`` : null} + +
+
+ `; +} diff --git a/packages/lit-components/src/components/TimelineEntry/index.ts b/packages/lit-components/src/components/TimelineEntry/index.ts new file mode 100644 index 0000000000..e95502bc5e --- /dev/null +++ b/packages/lit-components/src/components/TimelineEntry/index.ts @@ -0,0 +1 @@ +export * from './TimelineEntry'; diff --git a/packages/lit-components/src/components/TitledSection/TitledSection.stories.ts b/packages/lit-components/src/components/TitledSection/TitledSection.stories.ts new file mode 100644 index 0000000000..d2008d388b --- /dev/null +++ b/packages/lit-components/src/components/TitledSection/TitledSection.stories.ts @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; + +const spacingOptions = [ + 'none', + 'xxx-small', + 'xx-small', + 'x-small', + 'small', + 'medium', + 'large', + 'x-large', + 'xx-large', + 'xxx-large', + 'xxxx-large', +]; + +const renderSection = (args: Record) => html` + + ${args.useSlots + ? html` +

Reward summary

+
+

Invite a friend and earn credits after their first purchase.

+
+ ` + : html`

${args.content || 'Section content'}

`} +
+`; + +const meta: Meta = { + title: 'Components/TitledSection', + component: 'sql-titled-section', + tags: ['autodocs'], + argTypes: { + label: { control: 'text' }, + 'text-align': { control: 'select', options: ['left', 'center', 'right'] }, + 'label-margin': { control: 'select', options: spacingOptions }, + padding: { control: 'select', options: spacingOptions }, + }, + render: (args) => renderSection(args), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'Program overview', + content: 'Everything your advocates need to know in one place.', + }, +}; + +export const CenterAligned: Story = { + args: { + label: 'Centered content', + content: 'This section is centered for hero-style layouts.', + 'text-align': 'center', + }, +}; + +export const RightAligned: Story = { + args: { + label: 'Right aligned label', + content: 'Useful for asymmetric layouts and side panels.', + 'text-align': 'right', + }, +}; + +export const VariousPaddings: Story = { + render: () => html` +
+ ${['none', 'small', 'large', 'xxxx-large'].map( + (padding) => html` + +
+

The container uses ${padding} spacing.

+
+
+ ` + )} +
+ `, +}; + +export const VariousLabelMargins: Story = { + render: () => html` +
+ ${['none', 'x-small', 'medium', 'x-large'].map( + (margin) => html` + +
+

The label spacing uses ${margin}.

+
+
+ ` + )} +
+ `, +}; + +export const WithSlotContent: Story = { + args: { + useSlots: true, + padding: 'medium', + 'label-margin': 'x-small', + }, +}; diff --git a/packages/lit-components/src/components/TitledSection/TitledSection.ts b/packages/lit-components/src/components/TitledSection/TitledSection.ts new file mode 100644 index 0000000000..0d25696932 --- /dev/null +++ b/packages/lit-components/src/components/TitledSection/TitledSection.ts @@ -0,0 +1,44 @@ +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { TitledSectionView } from './TitledSectionView'; + +export type TitledSectionSpacing = + | 'none' + | 'xxx-small' + | 'xx-small' + | 'x-small' + | 'small' + | 'medium' + | 'large' + | 'x-large' + | 'xx-large' + | 'xxx-large' + | 'xxxx-large'; + +export interface TitledSectionProps { + label?: string; + textAlign: 'left' | 'center' | 'right'; + labelMargin: TitledSectionSpacing; + padding: TitledSectionSpacing; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-titled-section': HTMLElement; + } +} + +export const TitledSection = useComponent( + (host) => { + const props: TitledSectionProps = { + textAlign: 'left', + labelMargin: 'small', + padding: 'none', + ...getProps(host), + } as TitledSectionProps; + + return TitledSectionView(props); + }, + 'sql-titled-section', + ['label', 'text-align', 'label-margin', 'padding'] as const +); diff --git a/packages/lit-components/src/components/TitledSection/TitledSectionView.ts b/packages/lit-components/src/components/TitledSection/TitledSectionView.ts new file mode 100644 index 0000000000..38cb151c2b --- /dev/null +++ b/packages/lit-components/src/components/TitledSection/TitledSectionView.ts @@ -0,0 +1,31 @@ +import { html } from 'lit'; +import { TitledSectionProps } from './TitledSection'; + +function getSpacingValue(spacing: TitledSectionProps['padding'] | TitledSectionProps['labelMargin']) { + return spacing === 'none' ? '0' : `var(--sl-spacing-${spacing})`; +} + +export function TitledSectionView(props: TitledSectionProps) { + return html` + +
+
+ ${props.label || ''} +
+ +
+ `; +} diff --git a/packages/lit-components/src/components/TitledSection/index.ts b/packages/lit-components/src/components/TitledSection/index.ts new file mode 100644 index 0000000000..225c8021c6 --- /dev/null +++ b/packages/lit-components/src/components/TitledSection/index.ts @@ -0,0 +1 @@ +export * from './TitledSection'; diff --git a/packages/lit-components/src/components/UserInfoForm/UserInfoForm.stories.ts b/packages/lit-components/src/components/UserInfoForm/UserInfoForm.stories.ts new file mode 100644 index 0000000000..1ea0c9d112 --- /dev/null +++ b/packages/lit-components/src/components/UserInfoForm/UserInfoForm.stories.ts @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import '../../index'; +import { UserInfoFormView } from './UserInfoFormView'; + +const meta: Meta = { + title: 'Components/UserInfoForm', + component: 'sql-user-info-form', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + headerText: 'Personal Information', + submitLabel: 'Continue', + firstNameLabel: 'First Name', + lastNameLabel: 'Last Name', + emailLabel: 'Email', + phoneLabel: 'Phone Number', + countryLabel: 'Country', + programId: 'demo-program', + firstName: 'Jane', + setFirstName: () => undefined, + lastName: 'Doe', + setLastName: () => undefined, + email: 'jane@example.com', + setEmail: () => undefined, + phone: '+1 555 555 5555', + setPhone: () => undefined, + country: 'United States', + setCountry: () => undefined, + error: '', + loading: false, + onSubmit: (event: Event) => event.preventDefault(), +}; + +export const Default: Story = { + render: () => UserInfoFormView(baseProps), +}; + +export const WithError: Story = { + render: () => + UserInfoFormView({ + ...baseProps, + error: 'First name, last name, and email are required', + }), +}; + +export const Loading: Story = { + render: () => + UserInfoFormView({ + ...baseProps, + loading: true, + }), +}; diff --git a/packages/lit-components/src/components/UserInfoForm/UserInfoForm.ts b/packages/lit-components/src/components/UserInfoForm/UserInfoForm.ts new file mode 100644 index 0000000000..925efffa30 --- /dev/null +++ b/packages/lit-components/src/components/UserInfoForm/UserInfoForm.ts @@ -0,0 +1,53 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { UserInfoFormView } from './UserInfoFormView'; +import { useDemoUserInfoForm, useUserInfoForm } from './useUserInfoForm'; + +export interface UserInfoFormProps { + headerText: string; + submitLabel: string; + firstNameLabel: string; + lastNameLabel: string; + emailLabel: string; + phoneLabel: string; + countryLabel: string; + programId?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-user-info-form': HTMLElement; + } +} + +export const UserInfoForm = useComponent( + (host) => { + const rawProps = getProps(host) as Partial>; + const props: UserInfoFormProps = { + headerText: typeof rawProps.headerText === 'string' ? rawProps.headerText : 'Personal Information', + submitLabel: typeof rawProps.submitLabel === 'string' ? rawProps.submitLabel : 'Continue', + firstNameLabel: typeof rawProps.firstNameLabel === 'string' ? rawProps.firstNameLabel : 'First Name', + lastNameLabel: typeof rawProps.lastNameLabel === 'string' ? rawProps.lastNameLabel : 'Last Name', + emailLabel: typeof rawProps.emailLabel === 'string' ? rawProps.emailLabel : 'Email', + phoneLabel: typeof rawProps.phoneLabel === 'string' ? rawProps.phoneLabel : 'Phone Number', + countryLabel: typeof rawProps.countryLabel === 'string' ? rawProps.countryLabel : 'Country', + programId: typeof rawProps.programId === 'string' ? rawProps.programId : undefined, + }; + + const hookProps = isDemo() ? useDemoUserInfoForm(props) : useUserInfoForm(props); + + return UserInfoFormView({ ...props, ...hookProps }); + }, + 'sql-user-info-form', + [ + 'header-text', + 'submit-label', + 'first-name-label', + 'last-name-label', + 'email-label', + 'phone-label', + 'country-label', + 'program-id', + ] as const +); diff --git a/packages/lit-components/src/components/UserInfoForm/UserInfoFormView.ts b/packages/lit-components/src/components/UserInfoForm/UserInfoFormView.ts new file mode 100644 index 0000000000..f2c142e6b7 --- /dev/null +++ b/packages/lit-components/src/components/UserInfoForm/UserInfoFormView.ts @@ -0,0 +1,81 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import { UserInfoFormProps } from './UserInfoForm'; +import { useUserInfoForm } from './useUserInfoForm'; + +const getInputValue = (event: Event) => ((event.target as HTMLInputElement & { value: string })?.value ?? ''); + +export function UserInfoFormView(props: UserInfoFormProps & ReturnType) { + return html` + + + `; +} diff --git a/packages/lit-components/src/components/UserInfoForm/index.ts b/packages/lit-components/src/components/UserInfoForm/index.ts new file mode 100644 index 0000000000..590f140470 --- /dev/null +++ b/packages/lit-components/src/components/UserInfoForm/index.ts @@ -0,0 +1 @@ +export * from './UserInfoForm'; diff --git a/packages/lit-components/src/components/UserInfoForm/useUserInfoForm.test.ts b/packages/lit-components/src/components/UserInfoForm/useUserInfoForm.test.ts new file mode 100644 index 0000000000..bcf0a8111d --- /dev/null +++ b/packages/lit-components/src/components/UserInfoForm/useUserInfoForm.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useProgramId: vi.fn(() => 'test-program'), +})); + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + const values: unknown[] = []; + + return { + setters, + values, + useState: vi.fn((initial: unknown) => { + const index = setters.length; + const setter = vi.fn(); + setters.push(setter); + return [index < values.length ? values[index] : initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useUserInfoForm } from './useUserInfoForm'; + +describe('useUserInfoForm', () => { + const createEvent = () => ({ preventDefault: vi.fn() }) as unknown as Event; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + universalHooksMock.setters.length = 0; + universalHooksMock.values.length = 0; + componentBoilerplateMock.useProgramId.mockReturnValue('test-program'); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + }); + + it('should return initial state', () => { + const result = useUserInfoForm({ programId: '' } as any); + + expect(result.firstName).toBe(''); + expect(result.lastName).toBe(''); + expect(result.email).toBe(''); + expect(result.phone).toBe(''); + expect(result.country).toBe(''); + expect(result.error).toBe(''); + expect(result.loading).toBe(false); + }); + + it('should validate required fields', async () => { + const result = useUserInfoForm({ programId: '' } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[5]).toHaveBeenCalledWith( + 'First name, last name, and email are required' + ); + }); + + it('should dispatch the user info submit event', async () => { + universalHooksMock.values.push('Ada', 'Lovelace', 'ada@example.com', '555-1234', 'CA'); + const dispatchSpy = document.dispatchEvent as ReturnType; + const result = useUserInfoForm({ programId: 'fallback-program' } as any); + + await result.onSubmit(createEvent()); + + expect(universalHooksMock.setters[6]).toHaveBeenNthCalledWith(1, true); + expect(universalHooksMock.setters[5]).toHaveBeenCalledWith(''); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:user-info-submit'); + expect(event.detail).toEqual({ + firstName: 'Ada', + lastName: 'Lovelace', + email: 'ada@example.com', + phone: '555-1234', + country: 'CA', + programId: 'test-program', + }); + expect(universalHooksMock.setters[6]).toHaveBeenNthCalledWith(2, false); + }); +}); diff --git a/packages/lit-components/src/components/UserInfoForm/useUserInfoForm.ts b/packages/lit-components/src/components/UserInfoForm/useUserInfoForm.ts new file mode 100644 index 0000000000..67812d8d13 --- /dev/null +++ b/packages/lit-components/src/components/UserInfoForm/useUserInfoForm.ts @@ -0,0 +1,78 @@ +import { useProgramId } from '@saasquatch/component-boilerplate'; +import { useState } from '@saasquatch/universal-hooks'; +import { UserInfoFormProps } from './UserInfoForm'; + +export function useUserInfoForm(props: UserInfoFormProps) { + const programId = useProgramId() || props.programId; + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [email, setEmail] = useState(''); + const [phone, setPhone] = useState(''); + const [country, setCountry] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function onSubmit(e: Event) { + e.preventDefault(); + if (!firstName || !lastName || !email) { + setError('First name, last name, and email are required'); + return; + } + setLoading(true); + setError(''); + const event = new CustomEvent('sq:user-info-submit', { + bubbles: true, + composed: true, + detail: { firstName, lastName, email, phone, country, programId }, + }); + document.dispatchEvent(event); + setLoading(false); + } + + return { + firstName, + setFirstName, + lastName, + setLastName, + email, + setEmail, + phone, + setPhone, + country, + setCountry, + error, + loading, + onSubmit, + }; +} + +export function useDemoUserInfoForm(_props: UserInfoFormProps): ReturnType { + const [firstName, setFirstName] = useState('Jane'); + const [lastName, setLastName] = useState('Doe'); + const [email, setEmail] = useState('jane@example.com'); + const [phone, setPhone] = useState('+1 555 123 4567'); + const [country, setCountry] = useState('United States'); + const [error, setError] = useState(''); + const [loading] = useState(false); + + async function onSubmit(event: Event) { + event.preventDefault(); + setError(''); + } + + return { + firstName, + setFirstName, + lastName, + setLastName, + email, + setEmail, + phone, + setPhone, + country, + setCountry, + error, + loading, + onSubmit, + }; +} diff --git a/packages/lit-components/src/components/UserName/UserName.stories.ts b/packages/lit-components/src/components/UserName/UserName.stories.ts new file mode 100644 index 0000000000..8975159db7 --- /dev/null +++ b/packages/lit-components/src/components/UserName/UserName.stories.ts @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { UserNameView } from './UserNameView'; + +const setupGlobals = () => { + // @ts-expect-error setup globals + window.squatchTenant = 'ac52kfybp1tkr'; + // @ts-expect-error setup globals + window.squatchConfig = { + domain: 'https://staging.referralsaasquatch.com', + }; + // @ts-expect-error setup globals + window.squatchToken = '******'; + // @ts-expect-error setup globals + window.widgetIdent = { + programId: '41863', + userId: 'testuser', + accountId: 'testuser', + token: '******', + tenantAlias: 'ac52kfybp1tkr', + engagementMedium: 'EMBED', + appDomain: 'https://staging.referralsaasquatch.com', + }; +}; + +const meta: Meta = { + title: 'Components/UserName', + component: 'sql-user-name', + tags: ['autodocs'], + argTypes: { + 'fallback-text': { control: 'text' }, + }, + render: (args) => { + setupGlobals(); + return html``; + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + render: () => UserNameView({ fallbackText: 'Anonymous', displayName: '', loading: true }), +}; + +export const WithFallback: Story = { + render: () => + UserNameView({ fallbackText: 'Anonymous', displayName: 'Anonymous', loading: false }), +}; diff --git a/packages/lit-components/src/components/UserName/UserName.ts b/packages/lit-components/src/components/UserName/UserName.ts new file mode 100644 index 0000000000..fca65de2ce --- /dev/null +++ b/packages/lit-components/src/components/UserName/UserName.ts @@ -0,0 +1,30 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { UserNameView } from './UserNameView'; +import { useDemoUserName, useUserName } from './useUserName'; + +export interface UserNameProps { + fallbackText: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-user-name': HTMLElement; + } +} + +export const UserName = useComponent( + (host) => { + const props: UserNameProps = { + fallbackText: 'Anonymous', + ...getProps(host), + }; + + const hookProps = isDemo() ? useDemoUserName(props) : useUserName(props); + + return UserNameView({ ...props, ...hookProps }); + }, + 'sql-user-name', + ['fallback-text'] as const +); diff --git a/packages/lit-components/src/components/UserName/UserNameView.ts b/packages/lit-components/src/components/UserName/UserNameView.ts new file mode 100644 index 0000000000..0b5dd93e4b --- /dev/null +++ b/packages/lit-components/src/components/UserName/UserNameView.ts @@ -0,0 +1,14 @@ +import { html } from 'lit'; +import { UserNameProps } from './UserName'; +import { useUserName } from './useUserName'; + +export function UserNameView(props: UserNameProps & ReturnType) { + return html` + + ${props.loading ? '...' : props.displayName} + `; +} diff --git a/packages/lit-components/src/components/UserName/index.ts b/packages/lit-components/src/components/UserName/index.ts new file mode 100644 index 0000000000..ff984d41a7 --- /dev/null +++ b/packages/lit-components/src/components/UserName/index.ts @@ -0,0 +1 @@ +export * from './UserName'; diff --git a/packages/lit-components/src/components/UserName/useUserName.test.ts b/packages/lit-components/src/components/UserName/useUserName.test.ts new file mode 100644 index 0000000000..4d0673cb13 --- /dev/null +++ b/packages/lit-components/src/components/UserName/useUserName.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const componentBoilerplateMock = vi.hoisted(() => ({ + useUserIdentity: vi.fn(() => ({ jwt: 'test-jwt' })), + useQuery: vi.fn(() => ({ data: { viewer: { firstName: 'Jane', lastName: 'Doe' } }, loading: false })), +})); + +vi.mock('@saasquatch/component-boilerplate', () => componentBoilerplateMock); + +import { useUserName } from './useUserName'; + +describe('useUserName', () => { + beforeEach(() => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { firstName: 'Jane', lastName: 'Doe' } }, + loading: false, + }); + }); + + it('returns the full name when both names are present', () => { + const result = useUserName({ fallbackText: 'Friend' } as any); + + expect(result.displayName).toBe('Jane Doe'); + }); + + it('returns only the first name when the last name is missing', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { firstName: 'Jane', lastName: '' } }, + loading: false, + }); + + const result = useUserName({ fallbackText: 'Friend' } as any); + + expect(result.displayName).toBe('Jane'); + }); + + it('returns the fallback text when no name is available', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ + data: { viewer: { firstName: '', lastName: '' } }, + loading: false, + }); + + const result = useUserName({ fallbackText: 'Friend' } as any); + + expect(result.displayName).toBe('Friend'); + }); + + it('returns the loading state from the query', () => { + componentBoilerplateMock.useQuery.mockReturnValue({ data: undefined, loading: true }); + + const result = useUserName({ fallbackText: 'Friend' } as any); + + expect(result.loading).toBe(true); + }); +}); diff --git a/packages/lit-components/src/components/UserName/useUserName.ts b/packages/lit-components/src/components/UserName/useUserName.ts new file mode 100644 index 0000000000..6d7d870ac4 --- /dev/null +++ b/packages/lit-components/src/components/UserName/useUserName.ts @@ -0,0 +1,29 @@ +import { useQuery, useUserIdentity } from '@saasquatch/component-boilerplate'; +import { gql } from 'graphql-request'; +import { UserNameProps } from './UserName'; + +const USER_NAME_QUERY = gql` + query getUserName { + viewer { + ... on User { + firstName + lastName + } + } + } +`; + +export function useUserName(props: UserNameProps) { + const user = useUserIdentity(); + const { data, loading } = useQuery(USER_NAME_QUERY, {}, !user?.jwt); + + const firstName = data?.viewer?.firstName || ''; + const lastName = data?.viewer?.lastName || ''; + const displayName = [firstName, lastName].filter(Boolean).join(' ') || props.fallbackText; + + return { displayName, loading }; +} + +export function useDemoUserName(_props: UserNameProps) { + return { displayName: 'Jane Doe', loading: false }; +} diff --git a/packages/lit-components/src/components/WidgetVerification/WidgetVerification.stories.ts b/packages/lit-components/src/components/WidgetVerification/WidgetVerification.stories.ts new file mode 100644 index 0000000000..0cbbed7af8 --- /dev/null +++ b/packages/lit-components/src/components/WidgetVerification/WidgetVerification.stories.ts @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; +import '../../index'; +import { WidgetVerificationView } from './WidgetVerificationView'; + +const meta: Meta = { + title: 'Components/WidgetVerification', + component: 'sql-widget-verification', + tags: ['autodocs'], + argTypes: { + 'verification-type': { control: 'select', options: ['email', 'code'] }, + 'header-text': { control: 'text' }, + 'description-text': { control: 'text' }, + }, + render: (args) => html` + + ${(args['verification-type'] || 'email') === 'code' + ? html`` + : html``} + + `, +}; + +export default meta; +type Story = StoryObj; + +const baseProps = { + verificationType: 'email' as const, + headerText: 'Verify Your Identity', + descriptionText: 'Choose a verification method to continue.', +}; + +export const Default: Story = { + args: { + 'verification-type': 'email', + 'header-text': 'Verify Your Identity', + 'description-text': 'Choose a verification method to continue.', + }, +}; + +export const Verifying: Story = { + render: () => + WidgetVerificationView({ + ...baseProps, + verified: false, + step: 'verifying', + onVerify: () => {}, + }), +}; + +export const Success: Story = { + render: () => + WidgetVerificationView({ + ...baseProps, + verified: true, + step: 'success', + onVerify: () => {}, + }), +}; + +export const CodeType: Story = { + args: { + 'verification-type': 'code', + 'header-text': 'Enter Your Verification Code', + 'description-text': 'Provide the code we sent to your email to continue.', + }, +}; diff --git a/packages/lit-components/src/components/WidgetVerification/WidgetVerification.ts b/packages/lit-components/src/components/WidgetVerification/WidgetVerification.ts new file mode 100644 index 0000000000..1f60e680a7 --- /dev/null +++ b/packages/lit-components/src/components/WidgetVerification/WidgetVerification.ts @@ -0,0 +1,34 @@ +import { isDemo } from '@saasquatch/component-boilerplate'; +import { getProps } from '../../helpers'; +import { useComponent } from '../../hooks/useComponent'; +import { useDemoWidgetVerification, useWidgetVerification } from './useWidgetVerification'; +import { WidgetVerificationView } from './WidgetVerificationView'; + +export interface WidgetVerificationProps { + verificationType: 'email' | 'code'; + headerText: string; + descriptionText?: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'sql-widget-verification': HTMLElement; + } +} + +export const WidgetVerification = useComponent( + (host) => { + const rawProps = getProps(host); + const props: WidgetVerificationProps = { + verificationType: rawProps.verificationType === 'code' ? 'code' : 'email', + headerText: rawProps.headerText || 'Verify Your Identity', + descriptionText: rawProps.descriptionText || '', + }; + + const hookProps = isDemo() ? useDemoWidgetVerification(props) : useWidgetVerification(props); + + return WidgetVerificationView({ ...props, ...hookProps }); + }, + 'sql-widget-verification', + ['verification-type', 'header-text', 'description-text'] as const +); diff --git a/packages/lit-components/src/components/WidgetVerification/WidgetVerificationView.ts b/packages/lit-components/src/components/WidgetVerification/WidgetVerificationView.ts new file mode 100644 index 0000000000..fcaaca1f65 --- /dev/null +++ b/packages/lit-components/src/components/WidgetVerification/WidgetVerificationView.ts @@ -0,0 +1,54 @@ +import { html } from 'lit'; +import { UI } from '../../ui'; +import type { WidgetVerificationProps } from './WidgetVerification'; +import { useWidgetVerification } from './useWidgetVerification'; + +export function WidgetVerificationView( + props: WidgetVerificationProps & ReturnType +) { + return html` + +
+ ${props.step === 'success' + ? html` + ${UI.Icon({ name: 'check-circle', style: 'font-size: 3rem; color: var(--sl-color-success-600);' })} +

Verified successfully!

+ + ` + : props.step === 'verifying' + ? html` + ${UI.Spinner({ style: 'font-size: 2rem;' })} +

Verifying...

+ ` + : html` +

${props.headerText}

+ ${props.descriptionText + ? html`

${props.descriptionText}

` + : ''} + + `} +
+ `; +} diff --git a/packages/lit-components/src/components/WidgetVerification/index.ts b/packages/lit-components/src/components/WidgetVerification/index.ts new file mode 100644 index 0000000000..9c2e39585a --- /dev/null +++ b/packages/lit-components/src/components/WidgetVerification/index.ts @@ -0,0 +1 @@ +export * from './WidgetVerification'; diff --git a/packages/lit-components/src/components/WidgetVerification/useWidgetVerification.test.ts b/packages/lit-components/src/components/WidgetVerification/useWidgetVerification.test.ts new file mode 100644 index 0000000000..31b47b7200 --- /dev/null +++ b/packages/lit-components/src/components/WidgetVerification/useWidgetVerification.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const universalHooksMock = vi.hoisted(() => { + const setters: Array> = []; + + return { + setters, + useState: vi.fn((initial: unknown) => { + const setter = vi.fn(); + setters.push(setter); + return [initial, setter] as const; + }), + }; +}); + +vi.mock('@saasquatch/universal-hooks', () => ({ + useState: universalHooksMock.useState, +})); + +import { useWidgetVerification } from './useWidgetVerification'; + +describe('useWidgetVerification', () => { + beforeEach(() => { + universalHooksMock.setters.length = 0; + universalHooksMock.useState.mockClear(); + vi.restoreAllMocks(); + vi.stubGlobal('document', { dispatchEvent: vi.fn() }); + vi.useFakeTimers(); + }); + + it('should return the initial verification state', () => { + const result = useWidgetVerification({ verificationType: 'email', headerText: 'Verify' }); + + expect(result.verified).toBe(false); + expect(result.step).toBe('input'); + }); + + it('should dispatch a widget verification event and set step to verifying', () => { + const dispatchSpy = vi.spyOn(document, 'dispatchEvent'); + const result = useWidgetVerification({ verificationType: 'code', headerText: 'Verify' }); + + result.onVerify('123456'); + + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith('verifying'); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('sq:widget-verify'); + expect(event.detail).toEqual({ type: 'code', code: '123456' }); + }); + + it('should mark verification as successful after the timeout', () => { + const result = useWidgetVerification({ verificationType: 'email', headerText: 'Verify' }); + + result.onVerify('abc123'); + vi.advanceTimersByTime(1000); + + expect(universalHooksMock.setters[0]).toHaveBeenCalledWith(true); + expect(universalHooksMock.setters[1]).toHaveBeenCalledWith('success'); + }); +}); diff --git a/packages/lit-components/src/components/WidgetVerification/useWidgetVerification.ts b/packages/lit-components/src/components/WidgetVerification/useWidgetVerification.ts new file mode 100644 index 0000000000..9d3d9cde0f --- /dev/null +++ b/packages/lit-components/src/components/WidgetVerification/useWidgetVerification.ts @@ -0,0 +1,37 @@ +import { useState } from '@saasquatch/universal-hooks'; +import type { WidgetVerificationProps } from './WidgetVerification'; + +export function useWidgetVerification(props: WidgetVerificationProps) { + const [verified, setVerified] = useState(false); + const [step, setStep] = useState<'input' | 'verifying' | 'success' | 'error'>('input'); + + function onVerify(code: string) { + setStep('verifying'); + const event = new CustomEvent('sq:widget-verify', { + bubbles: true, + composed: true, + detail: { type: props.verificationType, code }, + }); + document.dispatchEvent(event); + setTimeout(() => { + setVerified(true); + setStep('success'); + }, 1000); + } + + return { verified, step, onVerify }; +} + +export function useDemoWidgetVerification( + _props: WidgetVerificationProps +): ReturnType { + const [verified, setVerified] = useState(false); + const [step, setStep] = useState<'input' | 'verifying' | 'success' | 'error'>('input'); + + function onVerify(_code: string) { + setVerified(true); + setStep('success'); + } + + return { verified, step, onVerify }; +} diff --git a/packages/lit-components/src/examples.ts b/packages/lit-components/src/examples.ts new file mode 100644 index 0000000000..6336cac18b --- /dev/null +++ b/packages/lit-components/src/examples.ts @@ -0,0 +1,33 @@ +import { CounterComponent } from './index'; + +/** + * Example usage of Lit Components + * + * This demonstrates how to use the web components from this library. + */ + +// Example 1: Using in HTML with custom attributes +const example1 = ` + + +