diff --git a/.env.example b/.env.example index 9ff0463db..ef0c3344a 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ SITE_NAME="Next.js Commerce" SHOPIFY_REVALIDATION_SECRET="" SHOPIFY_STOREFRONT_ACCESS_TOKEN="" SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com" +NEXT_PUBLIC_SHOPIFY_SHOP_ID="[your-shopify-shop-id]" diff --git a/.gitignore b/.gitignore index 0298027e4..5da11f77f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.idea diff --git a/README.md b/README.md index 981685d2b..ad2884188 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,22 @@ A Next.js 14 and App Router-ready ecommerce template featuring: - Styling with Tailwind CSS - Checkout and payments with Shopify - Automatic light/dark mode based on system settings +- Shopify Analytics

-> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1). +> Note: Looking for Next.js Commerce v1? View +> the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), +> and [release notes](https://github.com/vercel/commerce/releases/tag/v1). ## Providers -Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966). +Vercel will only be actively maintaining a Shopify +version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966). -Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged. +Vercel is happy to partner and work with any commerce provider to help them get a similar template up and running and +listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with +their own implementation while leaving the rest of the template mostly unchanged. - Shopify (this repository) - [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/)) @@ -34,21 +40,41 @@ Vercel is happy to partner and work with any commerce provider to help them get - [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/)) - [Wix](https://github.com/wix/nextjs-commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/)) -> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing). +> Note: Providers, if you are looking to use similar products for your demo, you +> can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing). ## Integrations Integrations enable upgraded or additional functionality for Next.js Commerce - [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/)) - - Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration. + - Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based + configuration. - Search runs entirely in the browser for smaller catalogs or on a CDN for larger. +## Shopify analytics + +1. Visit https://[your-store-id].myshopify.com/shop.json +2. Search for `shopId` and add it to the `NEXT_PUBLIC_SHOPIFY_SHOP_ID` in your `.env` file. + +To test out Shopify analytics, in your localhost, you can use the following steps: + +1. Install ngrok +2. Setup custom domain in ngrok dashboard +3. Expose your local development server (e.g., running on port 3000) with a command: + +```bash +ngrok http --domain=YOUR_NGROK_DOMAIN 3000 +``` + ## Running locally -You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary. +You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's +recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for +this, but a `.env` file is all that is necessary. -> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store. +> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify +> store. 1. Install Vercel CLI: `npm i -g vercel` 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link` @@ -69,8 +95,11 @@ Your app should now be running on [localhost:3000](http://localhost:3000/). 1. Connect to the existing `commerce-shopify` project. 1. Run `vc env pull` to get environment variables. 1. Run `pnpm dev` to ensure everything is working correctly. + ## Vercel, Next.js Commerce, and Shopify Integration Guide -You can use this comprehensive [integration guide](http://vercel.com/docs/integrations/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel. +You can use this comprehensive [integration guide](http://vercel.com/docs/integrations/shopify) with step-by-step +instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on +Vercel. diff --git a/app/layout.tsx b/app/layout.tsx index 58f5a9708..d6dd4e23d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import Navbar from 'components/layout/navbar'; import { GeistSans } from 'geist/font'; +import ShopifyAnalytics from 'components/layout/shopify-analytics'; import { ensureStartsWith } from 'lib/utils'; import { ReactNode, Suspense } from 'react'; import './globals.css'; @@ -39,6 +40,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
{children}
+ ); diff --git a/components/cart/actions.ts b/components/cart/actions.ts index fa2c34d37..0da2f5f37 100644 --- a/components/cart/actions.ts +++ b/components/cart/actions.ts @@ -4,10 +4,23 @@ import { TAGS } from 'lib/constants'; import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify'; import { revalidateTag } from 'next/cache'; import { cookies } from 'next/headers'; +import { ShopifyAnalyticsProduct } from '@shopify/hydrogen-react'; +import { productToAnalytics } from 'lib/utils'; -export async function addItem(prevState: any, selectedVariantId: string | undefined) { +type AddItemResponse = { + cartId?: string; + success: boolean; + message?: string; + products?: ShopifyAnalyticsProduct[]; +}; + +export async function addItem( + prevState: any, + selectedVariantId: string | undefined +): Promise { let cartId = cookies().get('cartId')?.value; let cart; + const quantity = 1; if (cartId) { cart = await getCart(cartId); @@ -20,14 +33,20 @@ export async function addItem(prevState: any, selectedVariantId: string | undefi } if (!selectedVariantId) { - return 'Missing product variant ID'; + return { success: false, message: 'Missing product variant ID' }; } try { - await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]); + const response = await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity }]); revalidateTag(TAGS.cart); + return { + success: true, + message: 'Item added to cart', + cartId, + products: productToAnalytics(response.lines, quantity, selectedVariantId) + }; } catch (e) { - return 'Error adding item to cart'; + return { success: false, message: 'Error adding item to cart' }; } } diff --git a/components/cart/add-to-cart.tsx b/components/cart/add-to-cart.tsx index 5e7afbff9..1f9e632ec 100644 --- a/components/cart/add-to-cart.tsx +++ b/components/cart/add-to-cart.tsx @@ -7,6 +7,8 @@ import LoadingDots from 'components/loading-dots'; import { ProductVariant } from 'lib/shopify/types'; import { useSearchParams } from 'next/navigation'; import { useFormState, useFormStatus } from 'react-dom'; +import { useEffect } from 'react'; +import { useShopifyAnalytics } from 'lib/shopify/hooks/use-shopify-analytics'; function SubmitButton({ availableForSale, @@ -70,7 +72,8 @@ export function AddToCart({ variants: ProductVariant[]; availableForSale: boolean; }) { - const [message, formAction] = useFormState(addItem, null); + const { sendAddToCart } = useShopifyAnalytics(); + const [response, formAction] = useFormState(addItem, null); const searchParams = useSearchParams(); const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined; const variant = variants.find((variant: ProductVariant) => @@ -81,12 +84,24 @@ export function AddToCart({ const selectedVariantId = variant?.id || defaultVariantId; const actionWithVariant = formAction.bind(null, selectedVariantId); + useEffect(() => { + if (response?.success && response.cartId) { + sendAddToCart({ + cartId: response.cartId, + products: response.products, + totalValue: Number(response.products?.[0]?.price) + }); + } + }, [response?.success, response?.cartId, sendAddToCart, response?.products]); + return (
-

- {message} -

+ {response?.message && ( +

+ {response.message} +

+ )} ); } diff --git a/components/layout/shopify-analytics.tsx b/components/layout/shopify-analytics.tsx new file mode 100644 index 000000000..8f1857080 --- /dev/null +++ b/components/layout/shopify-analytics.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useEffect } from 'react'; +import { AnalyticsEventName } from '@shopify/hydrogen-react'; +import { useShopifyAnalytics } from 'lib/shopify/hooks/use-shopify-analytics'; + +export default function ShopifyAnalytics() { + const { sendPageView, pathname } = useShopifyAnalytics(); + useEffect(() => { + sendPageView(AnalyticsEventName.PAGE_VIEW); + }, [pathname, sendPageView]); + return null; +} diff --git a/lib/constants.ts b/lib/constants.ts index 56bc6cd12..13efe4d30 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -29,3 +29,8 @@ export const TAGS = { export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden'; export const DEFAULT_OPTION = 'Default Title'; export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json'; + +export const DEFAULT_CURRENCY = 'AED'; + +// use your logic to get language +export const DEFAULT_LANGUAGE = 'EN'; diff --git a/lib/shopify/fragments/cart.ts b/lib/shopify/fragments/cart.ts index fc5c838dd..d6c89d3f3 100644 --- a/lib/shopify/fragments/cart.ts +++ b/lib/shopify/fragments/cart.ts @@ -37,6 +37,10 @@ const cartFragment = /* GraphQL */ ` name value } + price { + amount + currencyCode + } product { ...product } diff --git a/lib/shopify/hooks/use-shopify-analytics.ts b/lib/shopify/hooks/use-shopify-analytics.ts new file mode 100644 index 000000000..07de17f8a --- /dev/null +++ b/lib/shopify/hooks/use-shopify-analytics.ts @@ -0,0 +1,68 @@ +import { usePathname } from 'next/navigation'; +import { + AnalyticsEventName, + getClientBrowserParameters, + sendShopifyAnalytics, + ShopifyAnalyticsProduct, + ShopifyPageViewPayload, + ShopifySalesChannel, + useShopifyCookies +} from '@shopify/hydrogen-react'; +import { DEFAULT_CURRENCY, DEFAULT_LANGUAGE } from 'lib/constants'; + +const SHOP_ID = process.env.NEXT_PUBLIC_SHOPIFY_SHOP_ID!; + +type SendPageViewPayload = { + pageType?: string; + products?: ShopifyAnalyticsProduct[]; + collectionHandle?: string; + searchString?: string; + totalValue?: number; + cartId?: string; +}; + +type SendAddToCartPayload = { + cartId: string; + products?: ShopifyAnalyticsProduct[]; + totalValue?: ShopifyPageViewPayload['totalValue']; +}; + +export function useShopifyAnalytics() { + const pathname = usePathname(); + // send page view event + const sendPageView = ( + eventName: keyof typeof AnalyticsEventName, + payload?: SendPageViewPayload + ) => + sendShopifyAnalytics({ + eventName, + payload: { + ...getClientBrowserParameters(), + hasUserConsent: true, + shopifySalesChannel: ShopifySalesChannel.headless, + shopId: `gid://shopify/Shop/${SHOP_ID}`, + currency: DEFAULT_CURRENCY, + acceptedLanguage: DEFAULT_LANGUAGE, + ...payload + } + }); + + // send add to cart event + const sendAddToCart = ({ cartId, totalValue, products }: SendAddToCartPayload) => + sendPageView(AnalyticsEventName.ADD_TO_CART, { + cartId, + totalValue, + products + }); + + // setup cookies for shopify analytics & enable user consent + useShopifyCookies({ + hasUserConsent: true + }); + + return { + sendPageView, + sendAddToCart, + pathname + }; +} diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index e8b6637c8..9b3c893a2 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -2,7 +2,7 @@ import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/cons import { isShopifyError } from 'lib/type-guards'; import { ensureStartsWith } from 'lib/utils'; import { revalidateTag } from 'next/cache'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import { addToCartMutation, @@ -214,12 +214,19 @@ export async function addToCart( cartId: string, lines: { merchandiseId: string; quantity: number }[] ): Promise { + // get shopify cookies + const shopifyY = cookies()?.get('_shopify_y')?.value; + const shopifyS = cookies()?.get('_shopify_s')?.value; + const res = await shopifyFetch({ query: addToCartMutation, variables: { cartId, lines }, + headers: { + ...(shopifyY && shopifyS && { cookie: `_shopify_y=${shopifyY}; _shopify_s=${shopifyS};` }) + }, cache: 'no-store' }); return reshapeCart(res.body.data.cartLinesAdd.cart); diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index 23dc02d46..3afb70da9 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -21,6 +21,7 @@ export type CartItem = { merchandise: { id: string; title: string; + price: Money; selectedOptions: { name: string; value: string; diff --git a/lib/utils.ts b/lib/utils.ts index 69b76d29b..0745ef39a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,4 +1,6 @@ import { ReadonlyURLSearchParams } from 'next/navigation'; +import { Cart } from './shopify/types'; +import { ShopifyAnalyticsProduct } from '@shopify/hydrogen-react'; export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => { const paramsString = params.toString(); @@ -37,3 +39,30 @@ export const validateEnvironmentVariables = () => { ); } }; + +/** + * This function takes a cart and a quantity and returns an array of ShopifyAnalyticsProduct objects. + * */ +export const productToAnalytics = ( + cartItems: Cart['lines'], + quantity: number, + variantId: string +) => { + const line = cartItems.find((line) => line.merchandise.id === variantId); + if (!line) return; + + const { merchandise } = line; + + if (!merchandise) return; + + return [ + { + productGid: merchandise?.product.id, + variantGid: variantId, + name: merchandise?.product.title, + variantName: merchandise?.title, + price: merchandise?.price.amount, + quantity + } as ShopifyAnalyticsProduct + ]; +}; diff --git a/package.json b/package.json index a8c4bb2ba..440d7d8a8 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dependencies": { "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.3", + "@shopify/hydrogen-react": "^2024.1.1", "clsx": "^2.1.0", "geist": "^1.3.0", "next": "14.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f38def222..e18fcdd40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@heroicons/react': specifier: ^2.1.3 version: 2.1.3(react@18.2.0) + '@shopify/hydrogen-react': + specifier: ^2024.1.1 + version: 2024.1.1(@types/react@18.2.72)(react-dom@18.2.0)(react@18.2.0) clsx: specifier: ^2.1.0 version: 2.1.0 @@ -159,6 +162,14 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@google/model-viewer@1.12.1: + resolution: {integrity: sha512-GOf/By81rbxSmwWRVxBtlY5b3050msJ+BDWqonPj7M0/I7rNS/vVNjbLxTofbGjZObS3n0ELHj8TZ47UtkZbtg==} + engines: {node: '>=6.0.0'} + dependencies: + lit: 2.8.0 + three: 0.139.2 + dev: false + /@headlessui/react@1.7.18(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-4i5DOrzwN4qSgNsL4Si61VMkUcWbcSKueUV7sFhpHzQcSShdlHENE5+QBntMSRvHt8NyoFO2AGG8si9lq+w4zQ==} engines: {node: '>=10'} @@ -242,6 +253,16 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@lit-labs/ssr-dom-shim@1.2.0: + resolution: {integrity: sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g==} + dev: false + + /@lit/reactive-element@1.6.3: + resolution: {integrity: sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==} + dependencies: + '@lit-labs/ssr-dom-shim': 1.2.0 + dev: false + /@next/env@14.1.4: resolution: {integrity: sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==} dev: false @@ -365,6 +386,26 @@ packages: resolution: {integrity: sha512-0HejFckBN2W+ucM6cUOlwsByTKt9/+0tWhqUffNIcHqCXkthY/mZ7AuYPK/2IIaGWhdl0h+tICDO0ssLMd6XMQ==} dev: true + /@shopify/hydrogen-react@2024.1.1(@types/react@18.2.72)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-3ZDTUZPauXyV6TTxSjsEd0KEybT0jDpaEaB2tPtpjq783rJ1xD+wS9sWqqaKhAY2EUngg03muZiiuBeZIbR4Fw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@google/model-viewer': 1.12.1 + '@xstate/fsm': 2.1.0 + '@xstate/react': 3.2.2(@types/react@18.2.72)(@xstate/fsm@2.1.0)(react@18.2.0) + graphql: 16.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + type-fest: 4.14.0 + worktop: 0.7.3 + transitivePeerDependencies: + - '@types/react' + - xstate + dev: false + /@swc/helpers@0.5.2: resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} dependencies: @@ -422,7 +463,6 @@ packages: /@types/prop-types@15.7.12: resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} - dev: true /@types/react-dom@18.2.22: resolution: {integrity: sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==} @@ -435,7 +475,10 @@ packages: dependencies: '@types/prop-types': 15.7.12 csstype: 3.1.3 - dev: true + + /@types/trusted-types@2.0.7: + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + dev: false /@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3): resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} @@ -510,6 +553,30 @@ packages: requiresBuild: true dev: true + /@xstate/fsm@2.1.0: + resolution: {integrity: sha512-oJlc0iD0qZvAM7If/KlyJyqUt7wVI8ocpsnlWzAPl97evguPbd+oJbRM9R4A1vYJffYH96+Bx44nLDE6qS8jQg==} + dev: false + + /@xstate/react@3.2.2(@types/react@18.2.72)(@xstate/fsm@2.1.0)(react@18.2.0): + resolution: {integrity: sha512-feghXWLedyq8JeL13yda3XnHPZKwYDN5HPBLykpLeuNpr9178tQd2/3d0NrH6gSd0sG5mLuLeuD+ck830fgzLQ==} + peerDependencies: + '@xstate/fsm': ^2.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + xstate: ^4.37.2 + peerDependenciesMeta: + '@xstate/fsm': + optional: true + xstate: + optional: true + dependencies: + '@xstate/fsm': 2.1.0 + react: 18.2.0 + use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.72)(react@18.2.0) + use-sync-external-store: 1.2.0(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + /acorn-jsx@5.3.2(acorn@8.11.3): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -944,7 +1011,6 @@ packages: /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - dev: true /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -1792,6 +1858,11 @@ packages: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true + /graphql@16.8.1: + resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + dev: false + /has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: true @@ -2255,6 +2326,28 @@ packages: wrap-ansi: 9.0.0 dev: true + /lit-element@3.3.3: + resolution: {integrity: sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==} + dependencies: + '@lit-labs/ssr-dom-shim': 1.2.0 + '@lit/reactive-element': 1.6.3 + lit-html: 2.8.0 + dev: false + + /lit-html@2.8.0: + resolution: {integrity: sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==} + dependencies: + '@types/trusted-types': 2.0.7 + dev: false + + /lit@2.8.0: + resolution: {integrity: sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==} + dependencies: + '@lit/reactive-element': 1.6.3 + lit-element: 3.3.3 + lit-html: 2.8.0 + dev: false + /locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -2941,6 +3034,11 @@ packages: set-function-name: 2.0.2 dev: true + /regexparam@2.0.2: + resolution: {integrity: sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==} + engines: {node: '>=8'} + dev: false + /regjsparser@0.10.0: resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==} hasBin: true @@ -3369,6 +3467,10 @@ packages: any-promise: 1.3.0 dev: true + /three@0.139.2: + resolution: {integrity: sha512-gV7q7QY8rogu7HLFZR9cWnOQAUedUhu2WXAnpr2kdXZP9YDKsG/0ychwQvWkZN5PlNw9mv5MoCTin6zNTXoONg==} + dev: false + /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3424,6 +3526,11 @@ packages: engines: {node: '>=8'} dev: true + /type-fest@4.14.0: + resolution: {integrity: sha512-on5/Cw89wwqGZQu+yWO0gGMGu8VNxsaW9SB2HE8yJjllEk7IDTwnSN1dUVldYILhYPN5HzD7WAaw2cc/jBfn0Q==} + engines: {node: '>=16'} + dev: false + /typed-array-buffer@1.0.2: resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} engines: {node: '>= 0.4'} @@ -3504,6 +3611,27 @@ packages: punycode: 2.3.1 dev: true + /use-isomorphic-layout-effect@1.1.2(@types/react@18.2.72)(react@18.2.0): + resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.72 + react: 18.2.0 + dev: false + + /use-sync-external-store@1.2.0(react@18.2.0): + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true @@ -3572,6 +3700,13 @@ packages: isexe: 2.0.0 dev: true + /worktop@0.7.3: + resolution: {integrity: sha512-WBHP1hk8pLP7ahAw13fugDWcO0SUAOiCD6DHT/bfLWoCIA/PL9u7GKdudT2nGZ8EGR1APbGCAI6ZzKG1+X+PnQ==} + engines: {node: '>=12'} + dependencies: + regexparam: 2.0.2 + dev: false + /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'}