commit c447c51e82a498c74f2c8901724287f0c479059b Author: Rami Bitar Date: Sat Oct 3 11:27:27 2026 -0400 Vite + Hono + TanStack port of the Shopify storefront Ports nextjs-templates/shopify-storefront to Vite, TanStack Router/Query, Tailwind v4 and Hono, without the AI store assistant. Co-Authored-By: Claude Opus 5.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f6af0d5 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Shopify Storefront +VITE_SHOPIFY_DOMAIN=mock.shop +# VITE_SHOPIFY_PUBLIC_ACCESS_TOKEN= +# VITE_SHOPIFY_API_VERSION=2026-07 + +# Absolute origin used for canonical / Open Graph URLs +# VITE_SITE_URL=http://localhost:5173 + +# Port for `yarn start` +# PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f82fbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +node_modules/* +dist +.yarn/install-state.gz +.env*.local +src/routeTree.gen.ts +*.tsbuildinfo + +# Temp tsconfig written by `hydrogen gql check`; normally cleaned up on exit +.hydrogen-gql-*.json +.tanstack diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..e186276 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Vite Shopify Storefront + +A Shopify storefront built with Vite, React 19, TanStack Router, TanStack Query, +Tailwind CSS v4, Hono and `@shopify/hydrogen`. It's a port of +`nextjs-templates/shopify-storefront` without the AI store assistant. + +## Getting started + +```sh +cp .env.example .env.local # defaults to the tokenless mock.shop +yarn install +yarn dev # http://localhost:5173 +``` + +| Script | What it does | +| ---------------- | --------------------------------------------------------- | +| `yarn dev` | Vite dev server with the Hono API mounted at `/api` | +| `yarn build` | Client build to `dist/client`, server build to `dist/server` | +| `yarn start` | Production Node server (API + static client), `PORT` or 3000 | +| `yarn typecheck` | `tsc` plus `hydrogen gql check` for the GraphQL documents | + +## Layout + +``` +server/ Hono API — classic customer accounts behind an httpOnly cookie + app.ts /api routes (mounted by @hono/vite-dev-server in dev) + account.ts login, logout, register, recover, reset, activate, me, customer + node.ts production entry: API + dist/client + SPA fallback +src/ + routes/ TanStack file routes (route tree is generated) + hooks/ TanStack Query options + hooks over the Storefront API + services/shopify Storefront client, catalogue and customer functions + graphql/ Typed `gql()` documents + components/ Storefront UI (header, cart drawer, product detail, ...) +``` + +## How the Next.js pieces map + +| Next.js | Here | +| -------------------------------- | ----------------------------------------------------- | +| `app/**/page.tsx` | `src/routes/**` (TanStack file routes) | +| `generateMetadata` / `metadata` | route `head()` via `src/lib/seo.ts` + `` | +| Server-side data fetching | route `loader`s seeding the TanStack Query cache | +| `app/api/account/*` route handlers | `server/account.ts` (Hono) | +| `next/headers` cookies | `hono/cookie` | +| `next/image` | `src/components/ui/image.tsx` (Shopify CDN `srcset`) | +| `useSearchParams` | `validateSearch` + `Route.useSearch()` | +| `NEXT_PUBLIC_*` env | `VITE_*` env | + +Catalogue and cart requests go straight from the browser to the Storefront API +with the public token. Account requests go through Hono so the customer access +token never reaches client JavaScript. diff --git a/index.html b/index.html new file mode 100644 index 0000000..9096d65 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Shop + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..8177e19 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "vite-shopify-storefront", + "type": "module", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite", + "build": "vite build && vite build --ssr server/node.ts --outDir dist/server", + "start": "NODE_ENV=production node dist/server/node.js", + "typecheck": "tsc --noEmit && hydrogen gql check" + }, + "packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e", + "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", + "@hono/node-server": "^2.1.3", + "@remixicon/react": "^4.9.0", + "@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607", + "@tanstack/react-query": "^5.104.1", + "@tanstack/react-router": "^1.170.41", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "framer-motion": "^12.42.2", + "hono": "^4.13.12", + "lucide-react": "^0.562.0", + "radix-ui": "^1.6.7", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "tailwind-merge": "^3.7.0", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@hono/vite-dev-server": "^0.26.1", + "@tailwindcss/vite": "^4.3.3", + "@tanstack/router-plugin": "^1.168.42", + "@types/node": "^26.6.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "tailwindcss": "^4.3.3", + "typescript": "^5.7.3", + "vite": "^8.3.2" + } +} diff --git a/server/account.ts b/server/account.ts new file mode 100644 index 0000000..0b45350 --- /dev/null +++ b/server/account.ts @@ -0,0 +1,168 @@ +// Classic Storefront customer accounts. The access token only ever lives in +// the httpOnly session cookie; none of these routes return it. +import { Hono } from 'hono'; +import { + activateAccount, + createCustomer, + customerErrorMessage, + getCustomer, + login, + logout, + recoverPassword, + resetPassword, + toCustomerGid, +} from '@/services/shopify/customer'; +import { + clearSessionToken, + getSessionToken, + setSessionToken, +} from './session'; + +type Body = Record; + +// A malformed body reads as empty, so each route's own validation answers it. +const readBody = (req: Request): Promise => + req.json().catch(() => ({})); + +export const account = new Hono(); + +// Minimal session probe for the header menu. +account.get('/me', async (c) => { + const token = getSessionToken(c); + if (!token) return c.json({ customer: null }); + + const customer = await getCustomer(token, 0); + if (!customer) return c.json({ customer: null }); + + return c.json({ + customer: { + displayName: customer.displayName, + email: customer.email, + firstName: customer.firstName, + }, + }); +}); + +// Full profile with recent orders, for the order history page. +account.get('/customer', async (c) => { + const token = getSessionToken(c); + if (!token) return c.json({ customer: null }); + + const customer = await getCustomer(token, 20); + return c.json({ customer }); +}); + +account.post('/login', async (c) => { + const { email, password } = await readBody(c.req.raw); + + if (!email || !password) { + return c.json({ error: 'Enter your email and password.' }, 400); + } + + const { token, errors } = await login(email, password); + + if (!token) { + // Shopify distinguishes wrong-password from unknown-email; collapse both so + // the form can't be used to enumerate accounts. + return c.json( + { + error: errors.length + ? 'Incorrect email or password.' + : customerErrorMessage(errors), + }, + 401 + ); + } + + setSessionToken(c, token.accessToken, token.expiresAt); + return c.json({ ok: true }); +}); + +account.post('/logout', async (c) => { + const token = getSessionToken(c); + if (token) await logout(token); + + clearSessionToken(c); + return c.json({ ok: true }); +}); + +account.post('/register', async (c) => { + const { email, password, firstName, lastName } = await readBody(c.req.raw); + + if (!email || !password) { + return c.json({ error: 'Enter your email and password.' }, 400); + } + + const { errors } = await createCustomer({ + email, + password, + firstName, + lastName, + }); + + if (errors.length) { + return c.json({ error: customerErrorMessage(errors) }, 400); + } + + // Sign the new customer straight in. Accounts needing email confirmation + // won't return a token yet, which is not an error. + const { token } = await login(email, password); + + if (token) { + setSessionToken(c, token.accessToken, token.expiresAt); + return c.json({ ok: true, signedIn: true }); + } + + return c.json({ ok: true, signedIn: false }); +}); + +account.post('/recover', async (c) => { + const { email } = await readBody(c.req.raw); + + if (email) await recoverPassword(email); + + // Always the same response, so the form can't reveal who has an account. + return c.json({ ok: true }); +}); + +account.post('/reset', async (c) => { + const { id, resetToken, password } = await readBody(c.req.raw); + + if (!id || !resetToken || !password) { + return c.json({ error: 'This reset link is incomplete.' }, 400); + } + + const { token, errors } = await resetPassword( + toCustomerGid(id), + resetToken, + password + ); + + if (!token) { + return c.json({ error: customerErrorMessage(errors) }, 400); + } + + setSessionToken(c, token.accessToken, token.expiresAt); + return c.json({ ok: true }); +}); + +account.post('/activate', async (c) => { + const { id, activationToken, password } = await readBody(c.req.raw); + + if (!id || !activationToken || !password) { + return c.json({ error: 'This activation link is incomplete.' }, 400); + } + + const { token, errors } = await activateAccount( + toCustomerGid(id), + activationToken, + password + ); + + if (!token) { + return c.json({ error: customerErrorMessage(errors) }, 400); + } + + setSessionToken(c, token.accessToken, token.expiresAt); + return c.json({ ok: true }); +}); diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..6b27e02 --- /dev/null +++ b/server/app.ts @@ -0,0 +1,17 @@ +// The API surface. In dev, @hono/vite-dev-server mounts this inside Vite; in +// production, server/node.ts serves it alongside the built client. +import { Hono } from 'hono'; +import { account } from './account'; + +const app = new Hono().basePath('/api'); + +app.route('/account', account); + +app.notFound((c) => c.json({ error: 'Not found' }, 404)); + +app.onError((err, c) => { + console.error(err); + return c.json({ error: 'Something went wrong. Please try again.' }, 500); +}); + +export default app; diff --git a/server/node.ts b/server/node.ts new file mode 100644 index 0000000..be9cba9 --- /dev/null +++ b/server/node.ts @@ -0,0 +1,30 @@ +// Production entry: the API plus the static client build, with an SPA fallback +// so deep links like /products/:handle load index.html. +import { readFile } from 'node:fs/promises'; +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import api from './app'; + +const CLIENT_DIR = './dist/client'; +const indexHtml = await readFile(`${CLIENT_DIR}/index.html`, 'utf8'); + +const app = new Hono(); + +app.route('/', api); +// Unknown API paths stay JSON 404s rather than falling through to the SPA. +app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404)); + +// Hashed build assets never change, so let browsers keep them. +app.use('/assets/*', async (c, next) => { + await next(); + c.header('Cache-Control', 'public, max-age=31536000, immutable'); +}); +app.use('*', serveStatic({ root: CLIENT_DIR })); +app.get('*', (c) => c.html(indexHtml)); + +const port = Number(process.env.PORT) || 3000; + +serve({ fetch: app.fetch, port }, (info) => { + console.log(`Storefront listening on http://localhost:${info.port}`); +}); diff --git a/server/session.ts b/server/session.ts new file mode 100644 index 0000000..694a019 --- /dev/null +++ b/server/session.ts @@ -0,0 +1,29 @@ +// Customer session, stored in an httpOnly cookie. Server-only: the access token +// must never reach the browser's JavaScript. +import type { Context } from 'hono'; +import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; +import { CUSTOMER_TOKEN_COOKIE } from '@/services/shopify/customer'; + +export function getSessionToken(c: Context): string | null { + return getCookie(c, CUSTOMER_TOKEN_COOKIE) ?? null; +} + +export function setSessionToken( + c: Context, + accessToken: string, + expiresAt: string +): void { + const expires = new Date(expiresAt); + + setCookie(c, CUSTOMER_TOKEN_COOKIE, accessToken, { + httpOnly: true, + secure: import.meta.env.PROD, + sameSite: 'Lax', + path: '/', + expires: Number.isNaN(expires.getTime()) ? undefined : expires, + }); +} + +export function clearSessionToken(c: Context): void { + deleteCookie(c, CUSTOMER_TOKEN_COOKIE, { path: '/' }); +} diff --git a/src/components/logo.tsx b/src/components/logo.tsx new file mode 100644 index 0000000..358a9ba --- /dev/null +++ b/src/components/logo.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Link, type LinkProps } from '@tanstack/react-router'; + +interface LogoProps { + /** Logo image URL. Falls back to the store name as a wordmark when absent. */ + src?: string | null; + /** Wordmark text, and the image's alt text. */ + storeName?: string; + /** Where the logo links to. Pass null to render it unwrapped. */ + href?: LinkProps['to'] | null; + /** Sizing for the image — height only, so the aspect ratio is preserved. */ + imageClassName?: string; + /** Sizing and weight for the wordmark fallback. */ + textClassName?: string; +} + +const Logo: React.FC = ({ + src, + storeName = 'Shop', + href = '/', + imageClassName = 'h-6', + textClassName = 'text-xl', +}) => { + const mark = src ? ( + {storeName} + ) : ( + + {storeName} + + ); + + if (href === null) { + return {mark}; + } + + return ( + + {mark} + + ); +}; + +export default Logo; diff --git a/src/components/route-states.tsx b/src/components/route-states.tsx new file mode 100644 index 0000000..1a211ae --- /dev/null +++ b/src/components/route-states.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Link } from '@tanstack/react-router'; + +// Ported from app/not-found.tsx and app/error.tsx. Both render inside the root +// layout, so the header and footer stay put. + +export function NotFound() { + return ( +
+
+
+ 404 +
+
+
+ This page could not be found.{' '} + + Go home + +
+
+
+ ); +} + +export function ErrorMessage({ error }: { error: unknown }) { + return ( +
+
+
+ Something went wrong +
+
+
+ {error instanceof Error ? error.message : String(error)} +
+
+
+ ); +} diff --git a/src/components/shopify/account-form.tsx b/src/components/shopify/account-form.tsx new file mode 100644 index 0000000..b5f4b3f --- /dev/null +++ b/src/components/shopify/account-form.tsx @@ -0,0 +1,147 @@ +import React, { useState } from 'react'; +import { Link, useNavigate, type LinkProps } from '@tanstack/react-router'; +import { useQueryClient } from '@tanstack/react-query'; +import { ACCOUNT_QUERY_KEY } from '@/hooks/use-account'; +import { Button } from '@/components/ui/button'; +import { Loader } from '@/components/ui/loader'; + +export interface AccountFormField { + name: string; + label: string; + type?: 'text' | 'email' | 'password'; + autoComplete?: string; + required?: boolean; +} + +interface AccountFormProps { + title: string; + description?: string; + fields: AccountFormField[]; + submitLabel: string; + endpoint: string; + /** Merged into the request body alongside the field values. */ + extraPayload?: Record; + /** Where to go on success; omit to show `successMessage` instead. */ + redirectTo?: LinkProps['to']; + successMessage?: string; + footer?: React.ReactNode; +} + +const AccountForm: React.FC = ({ + title, + description, + fields, + submitLabel, + endpoint, + extraPayload, + redirectTo, + successMessage, + footer, +}) => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [values, setValues] = useState>({}); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (submitting) return; + + try { + setSubmitting(true); + setError(null); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...values, ...extraPayload }), + }); + const data = await response.json(); + + if (!response.ok) { + setError(data.error ?? 'Something went wrong. Please try again.'); + return; + } + + // The session cookie may have changed, so drop any cached account state + // (header menu, order history) before moving on. + await queryClient.invalidateQueries({ queryKey: ACCOUNT_QUERY_KEY }); + + if (redirectTo) { + await navigate({ to: redirectTo }); + } else { + setDone(true); + } + } catch { + setError('Could not reach the server. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+

{title}

+ {description && ( +

{description}

+ )} + + {done && successMessage ? ( +

{successMessage}

+ ) : ( +
+ {fields.map((field) => ( + + ))} + + {error &&

{error}

} + + +
+ )} + + {footer && ( +
+ {footer} +
+ )} +
+ ); +}; + +export const AccountFormLink: React.FC<{ + to: LinkProps['to']; + children: React.ReactNode; +}> = ({ + to, + children, +}) => ( + + {children} + +); + +export default AccountForm; diff --git a/src/components/shopify/account-menu.tsx b/src/components/shopify/account-menu.tsx new file mode 100644 index 0000000..1c49624 --- /dev/null +++ b/src/components/shopify/account-menu.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + ACCOUNT_QUERY_KEY, + sessionQueryOptions, +} from '@/hooks/use-account'; +import { Button } from '@/components/ui/button'; +import { RiUserLine } from '@remixicon/react'; + +const AccountMenu: React.FC = () => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + + // The token lives in an httpOnly cookie, so the signed-in state has to come + // from the server rather than being read directly. + const { data: customer } = useQuery(sessionQueryOptions()); + + const handleSignOut = async () => { + setOpen(false); + await fetch('/api/account/logout', { method: 'POST' }); + queryClient.removeQueries({ queryKey: ACCOUNT_QUERY_KEY }); + queryClient.setQueryData(sessionQueryOptions().queryKey, null); + await navigate({ to: '/' }); + }; + + // Signed out: straight to the sign-in page, no menu. + if (!customer) { + return ( + + ); + } + + return ( +
+ + + {open && ( + <> +
setOpen(false)} /> +
+
+

+ {customer.firstName || customer.displayName} +

+

+ {customer.email} +

+
+ +
+ + setOpen(false)} + className="block px-4 py-2 text-sm text-foreground hover:bg-accent" + > + Order history + + +
+ + )} +
+ ); +}; + +export default AccountMenu; diff --git a/src/components/shopify/cart-drawer.tsx b/src/components/shopify/cart-drawer.tsx new file mode 100644 index 0000000..98e85b4 --- /dev/null +++ b/src/components/shopify/cart-drawer.tsx @@ -0,0 +1,349 @@ +import React, { useState } from 'react'; +import Image from '@/components/ui/image'; +import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart'; +import { Button } from '@/components/ui/button'; +import { Loader } from '@/components/ui/loader'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetBody, + AnimatePresence, +} from '@/components/ui/sheet'; +import { + RiCloseLine, + RiImageLine, + RiSubtractLine, + RiAddLine, +} from '@remixicon/react'; +import { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, +} from '@/components/ui/empty'; +import { isDefaultTitleSelection } from '@/services/shopify/catalog'; + +const CartDrawer: React.FC = () => { + const isOpen = useCartStore((s) => s.isOpen); + const closeCart = useCartStore((s) => s.closeCart); + const loading = useCartStore((s) => s.loading); + const cart = useCartStore((s) => s.cart); + const removeItem = useCartStore((s) => s.removeItem); + const updateItemQuantity = useCartStore((s) => s.updateItemQuantity); + const applyDiscountCode = useCartStore((s) => s.applyDiscountCode); + + const [discountCode, setDiscountCode] = useState(''); + const [discountError, setDiscountError] = useState(null); + const [applyingDiscount, setApplyingDiscount] = useState(false); + // The store's `loading` flag is global, so track the specific line being + // changed to keep the other rows interactive. + const [pendingLineId, setPendingLineId] = useState(null); + + const runLineAction = async (lineId: string, action: () => Promise) => { + if (pendingLineId) return; + + try { + setPendingLineId(lineId); + await action(); + } catch (err) { + console.error('Cart line update failed:', err); + } finally { + setPendingLineId(null); + } + }; + + const items = cart?.lines?.edges?.map((edge) => edge.node) ?? []; + const itemCount = items.reduce((sum, item) => sum + item.quantity, 0); + const totalAmount = parseFloat(cart?.cost?.totalAmount?.amount ?? '0'); + const checkoutUrl = cart?.checkoutUrl ?? null; + const appliedDiscounts = + cart?.discountCodes?.filter((discount) => discount.applicable) ?? []; + + const handleCheckout = () => { + if (checkoutUrl) { + redirectToCheckout(checkoutUrl); + } + }; + + const handleApplyDiscount = async (event: React.FormEvent) => { + event.preventDefault(); + const code = discountCode.trim(); + if (!code || applyingDiscount) return; + + try { + setApplyingDiscount(true); + setDiscountError(null); + const updatedCart = await applyDiscountCode(code); + + // Shopify accepts unknown codes silently, flagging them as inapplicable. + const accepted = updatedCart.discountCodes?.some( + (discount) => + discount.applicable && + discount.code.toLowerCase() === code.toLowerCase() + ); + + if (accepted) { + setDiscountCode(''); + } else { + setDiscountError('That code is not valid for this cart.'); + } + } catch { + setDiscountError('Could not apply that code. Please try again.'); + } finally { + setApplyingDiscount(false); + } + }; + + const getItemImage = (item: (typeof items)[0]) => { + return item.merchandise.image?.url; + }; + + const getSelectedOptions = (item: (typeof items)[0]) => { + // Single-SKU items carry a synthetic `Title: Default Title` — not worth a line. + return (item.merchandise.selectedOptions ?? []).filter( + (option) => !isDefaultTitleSelection(option) + ); + }; + + return ( + !open && closeCart()} + side="right" + > + + {isOpen && ( + + {/* Header */} + +
+ + Cart + + {itemCount} + + + +
+
+ + {/* Cart Items */} + + {loading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( + + + Your cart is empty + + Add some products to get started! + + + + + + + ) : ( +
+ {items.map((item) => { + const image = getItemImage(item); + const selectedOptions = getSelectedOptions(item); + const isPending = pendingLineId === item.id; + + return ( +
+ {/* Product Image */} +
+ {image ? ( + {item.merchandise.product.title} + ) : ( +
+ +
+ )} +
+ + {/* Product Details */} +
+
+

+ {item.merchandise.product.title} +

+ + $ + {parseFloat( + item.cost?.totalAmount?.amount ?? + item.merchandise.price.amount + ).toFixed(2)} + +
+ + {selectedOptions.length > 0 && ( +
+ {selectedOptions + .map((option) => option.value) + .join(' / ')} +
+ )} + + {/* Quantity Controls */} +
+
+ + + {isPending ? ( + + ) : ( + item.quantity + )} + + +
+ +
+
+
+ ); + })} +
+ )} +
+ + {/* Footer — discount, total, checkout */} + {items.length > 0 && ( +
+ {/* Discount Code */} +
+ { + setDiscountCode(event.target.value); + setDiscountError(null); + }} + placeholder="Discount code" + aria-label="Discount code" + className="flex-1 h-10 rounded-md border border-border px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors" + /> + +
+ + {discountError && ( +

{discountError}

+ )} + + {appliedDiscounts.length > 0 && ( +

+ Applied: {appliedDiscounts.map((d) => d.code).join(', ')} +

+ )} + + {/* Estimated total */} +
+
+ + Estimated total + + + ${totalAmount.toFixed(2)} + +
+

+ Taxes and shipping calculated at checkout. +

+
+ + + + +
+ )} +
+ )} +
+
+ ); +}; + +export default CartDrawer; diff --git a/src/components/shopify/collection-card.tsx b/src/components/shopify/collection-card.tsx new file mode 100644 index 0000000..1485830 --- /dev/null +++ b/src/components/shopify/collection-card.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Link } from '@tanstack/react-router'; +import Image from '@/components/ui/image'; + +interface CollectionImage { + url: string; + altText?: string | null; +} + +interface Collection { + id: string; + title: string; + handle: string; + description?: string; + image?: CollectionImage | null; +} + +interface CollectionCardProps { + collection: Collection; +} + +const CollectionCard: React.FC = ({ collection }) => { + return ( + + {/* Collection Image */} +
+ {collection.image ? ( + {collection.image.altText + ) : ( +
+ +
+ )} +
+ + {/* Collection Info */} +
+

+ {collection.title} +

+
+ + ); +}; + +export default CollectionCard; diff --git a/src/components/shopify/collection-detail.tsx b/src/components/shopify/collection-detail.tsx new file mode 100644 index 0000000..85a37c8 --- /dev/null +++ b/src/components/shopify/collection-detail.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useParams } from '@tanstack/react-router'; +import ProductCard from './product-card'; +import ProductFilters, { type ProductFilterFacet } from './product-filters'; +import ProductToolbar from './product-toolbar'; +import { Button } from '@/components/ui/button'; +import { Loader } from '@/components/ui/loader'; +import { + collectionProductsInfiniteQueryOptions, + type CollectionSortKey, +} from '@/hooks/use-shopify-collections'; + +const PAGE_SIZE = 24; + +const GRID_CLASSES = + 'grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12'; + +interface SortOption { + label: string; + sortKey: CollectionSortKey; + reverse: boolean; +} + +const SORT_OPTIONS: SortOption[] = [ + { label: 'Featured', sortKey: 'COLLECTION_DEFAULT', reverse: false }, + { label: 'Best Selling', sortKey: 'BEST_SELLING', reverse: false }, + { label: 'Price: Low to High', sortKey: 'PRICE', reverse: false }, + { label: 'Price: High to Low', sortKey: 'PRICE', reverse: true }, + { label: 'Newest', sortKey: 'CREATED', reverse: true }, +]; + +interface CollectionDetailProps { + handle: string; +} + +const CollectionDetail: React.FC = ({ handle }) => { + const [filters, setFilters] = useState([]); + const [sortIndex, setSortIndex] = useState(0); + const [filtersOpen, setFiltersOpen] = useState(false); + const [activeFilters, setActiveFilters] = useState([]); + + const sort = SORT_OPTIONS[sortIndex]; + + // Fall back to the handle until the collection's real title arrives. + const formattedTitle = handle + ? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) + : 'Collection'; + + // Sort and filters are part of the query key, so changing either starts a + // fresh first page. + const { + data, + isPending: loading, + error: queryError, + fetchNextPage, + hasNextPage, + isFetchingNextPage: loadingMore, + } = useInfiniteQuery( + collectionProductsInfiniteQueryOptions(handle, { + first: PAGE_SIZE, + sortKey: sort.sortKey, + reverse: sort.reverse, + filterInputs: activeFilters, + }) + ); + + const firstPage = data?.pages[0]; + const title = firstPage?.collection?.title ?? null; + const error = queryError + ? queryError.message || 'Failed to load collection' + : firstPage && !firstPage.collection + ? 'Collection not found' + : null; + + const products = useMemo(() => { + const seen = new Set(); + return (data?.pages ?? []) + .flatMap((page) => page.products) + .filter((product) => !seen.has(product.id) && seen.add(product.id)); + }, [data]); + + // Keep the facet list stable while a selection is active, so options don't + // disappear out from under the panel. + useEffect(() => { + if (firstPage && activeFilters.length === 0) setFilters(firstPage.filters); + }, [firstPage, activeFilters.length]); + + const handleLoadMore = () => { + if (!loadingMore && hasNextPage) fetchNextPage(); + }; + + return ( +
+
+

+ {title || formattedTitle} +

+ +
+ 0 ? products.length : null} + onOpenFilters={() => setFiltersOpen(true)} + sortOptions={SORT_OPTIONS} + sortIndex={sortIndex} + onSortChange={setSortIndex} + activeFilterCount={activeFilters.length} + /> +
+ +
+ {loading ? ( +
+ {Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : error ? ( +

{error}

+ ) : products.length === 0 ? ( +

+ {activeFilters.length > 0 + ? 'No products matched your filters.' + : "This collection doesn't have any products yet."} +

+ ) : ( + <> +
+ {products.map((product) => ( + + ))} +
+ + {hasNextPage && ( +
+ +
+ )} + + )} +
+
+ + +
+ ); +}; + +export default CollectionDetail; + +/** + * Route body for /collections/$handle — used as both the route component and + * its pending component, since the page skeleton comes from the same query. + */ +export function CollectionPage() { + const { handle } = useParams({ from: '/collections/$handle' }); + // Keyed so sort and filter state reset when moving between collections. + return ; +} diff --git a/src/components/shopify/collections.tsx b/src/components/shopify/collections.tsx new file mode 100644 index 0000000..cfd3ba4 --- /dev/null +++ b/src/components/shopify/collections.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { useCollections } from '@/hooks/use-shopify-collections'; +import CollectionCard from './collection-card'; +import { Button } from '@/components/ui/button'; + +interface CollectionsProps { + title?: string; + subtitle?: string; +} + +const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({ + title, + subtitle, +}) => ( + <> +

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} + +); + +const GRID_CLASSES = 'grid grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-12'; + +const Collections: React.FC = ({ + title = 'Our Collections', + subtitle = 'Discover our carefully crafted worlds', +}) => { + const { collections, loading, error, refetch } = useCollections(12); + + if (loading) { + return ( +
+
+ + +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+ +

{error}

+ +
+
+ ); + } + + if (collections.length === 0) { + return ( +
+
+ +

+ Collections will appear here once added to your Shopify store. +

+
+
+ ); + } + + return ( +
+
+ + +
+ {collections.map((collection) => ( + + ))} +
+
+
+ ); +}; + +export default Collections; diff --git a/src/components/shopify/footer.tsx b/src/components/shopify/footer.tsx new file mode 100644 index 0000000..c2dae6d --- /dev/null +++ b/src/components/shopify/footer.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { + RiInstagramLine, + RiTiktokLine, + RiFacebookFill, +} from '@remixicon/react'; +import { Link, linkOptions, type LinkOptions } from '@tanstack/react-router'; +import Logo from '@/components/logo'; + +export interface FooterLink { + label: string; + /** Built with `linkOptions()`, so dynamic routes carry typed params. */ + link: LinkOptions; +} + +const policyLink = (label: string, handle: string): FooterLink => ({ + label, + link: linkOptions({ to: '/policies/$handle', params: { handle } }), +}); + +interface FooterProps { + storeName?: string; + logoUrl?: string; + copyright?: string; + links?: FooterLink[]; + instagramUrl?: string; + tiktokUrl?: string; + facebookUrl?: string; +} + +const Footer: React.FC = ({ + storeName = 'Shop', + logoUrl, + copyright, + links = [ + policyLink('Terms of Service', 'terms-of-service'), + policyLink('Privacy Policy', 'privacy-policy'), + policyLink('Refund Policy', 'refund-policy'), + policyLink('Shipping Policy', 'shipping-policy'), + policyLink('Subscription Policy', 'subscription-policy'), + ], + instagramUrl = '#', + tiktokUrl = '#', + facebookUrl = '#', +}) => { + const socials = [ + { label: 'Instagram', url: instagramUrl, Icon: RiInstagramLine }, + { label: 'TikTok', url: tiktokUrl, Icon: RiTiktokLine }, + { label: 'Facebook', url: facebookUrl, Icon: RiFacebookFill }, + ]; + + return ( +
+
+ {/* Links and socials sit on separate rows so the icons aren't + crammed onto the end of the link list. */} +
+ + + + +
+ + {socials.map(({ label, url, Icon }) => ( + + + + ))} + + +

+ {copyright || `© ${storeName}. All rights reserved.`} +

+
+
+
+
+ ); +}; + +export default Footer; diff --git a/src/components/shopify/header.tsx b/src/components/shopify/header.tsx new file mode 100644 index 0000000..2d362ae --- /dev/null +++ b/src/components/shopify/header.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import { Link, type LinkProps } from '@tanstack/react-router'; +import { useCartStore } from '@/hooks/use-shopify-cart'; +import CartDrawer from '@/components/shopify/cart-drawer'; +import SearchDialog from '@/components/shopify/search-dialog'; +import AccountMenu from '@/components/shopify/account-menu'; +import ShopMenu from '@/components/shopify/shop-menu'; +import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react'; +import { Button } from '@/components/ui/button'; +import Logo from '@/components/logo'; + +const CartIcon: React.FC = () => { + const toggleCart = useCartStore((s) => s.toggleCart); + const cart = useCartStore((s) => s.cart); + const itemCount = + cart?.lines?.edges?.reduce((sum, { node }) => sum + node.quantity, 0) ?? 0; + + return ( + + ); +}; + +export interface NavLink { + label: string; + url: LinkProps['to']; +} + +interface HeaderProps { + storeName?: string; + logoUrl?: string; + links?: readonly NavLink[]; + /** Thin bar above the nav. Pass null to hide it. */ + announcement?: string | null; + announcementUrl?: LinkProps['to']; +} + +const Header: React.FC = ({ + storeName = 'Logo', + logoUrl, + links = [ + { label: 'Shop', url: '/' }, + { label: 'Collections', url: '/collections' }, + ], + announcement = 'Free shipping on orders over $100', + announcementUrl, +}) => { + const [menuOpen, setMenuOpen] = useState(false); + + return ( + <> + {/* Sits above the sticky nav, so it scrolls away on its own. */} + {announcement && ( +
+
+ {announcementUrl ? ( + + {announcement} + + ) : ( + {announcement} + )} +
+
+ )} + + + + ); +}; + +export default Header; diff --git a/src/components/shopify/order-history.tsx b/src/components/shopify/order-history.tsx new file mode 100644 index 0000000..975e741 --- /dev/null +++ b/src/components/shopify/order-history.tsx @@ -0,0 +1,167 @@ +import React, { useState } from 'react'; +import Image from '@/components/ui/image'; +import { RiImageLine } from '@remixicon/react'; +import type { Customer, CustomerOrder } from '@/services/shopify/customer'; + +interface OrderHistoryProps { + customer: Customer; +} + +const formatMoney = (amount: string, currencyCode: string) => { + const value = parseFloat(amount); + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + }).format(value); + } catch { + return `$${value.toFixed(2)}`; + } +}; + +const formatDate = (iso: string) => + new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + +const StatusPill: React.FC<{ label?: string | null }> = ({ label }) => { + if (!label) return null; + return ( + + {label.replace(/_/g, ' ').toLowerCase()} + + ); +}; + +const OrderHistory: React.FC = ({ customer }) => { + const orders: CustomerOrder[] = + customer.orders?.edges.map((edge) => edge.node) ?? []; + + // The Storefront API has no standalone order-by-id query for customers, so + // the detail is rendered from the order already loaded in this list. + const [selectedId, setSelectedId] = useState( + orders[0]?.id ?? null + ); + const selected = orders.find((order) => order.id === selectedId) ?? null; + + if (orders.length === 0) { + return ( +

+ You haven't placed any orders yet. +

+ ); + } + + return ( +
+ {/* List */} +
+

Orders

+
    + {orders.map((order) => { + const isSelected = order.id === selectedId; + + return ( +
  • + +
  • + ); + })} +
+
+ + {/* Detail — same screen, no navigation */} +
+ {selected && ( + <> +
+

+ Order #{selected.orderNumber} +

+ + {formatMoney( + selected.currentTotalPrice.amount, + selected.currentTotalPrice.currencyCode + )} + +
+ +
+ + Placed {formatDate(selected.processedAt)} + + + +
+ +
    + {selected.lineItems.edges.map(({ node }, index) => ( +
  • +
    + {node.variant?.image ? ( + {node.variant.image.altText + ) : ( +
    + +
    + )} +
    +
    +

    + {node.title} +

    +

    + Qty {node.quantity} +

    +
    +
  • + ))} +
+ + {selected.statusUrl && ( + + View order status + + )} + + )} +
+
+ ); +}; + +export default OrderHistory; diff --git a/src/components/shopify/product-card.tsx b/src/components/shopify/product-card.tsx new file mode 100644 index 0000000..9726c6c --- /dev/null +++ b/src/components/shopify/product-card.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { Link } from '@tanstack/react-router'; +import Image from '@/components/ui/image'; +import { truncate } from '@/lib/utils'; + +interface ProductImage { + url: string; + altText?: string | null; +} + +interface ProductPrice { + amount: string; + currencyCode: string; +} + +interface ProductVariant { + id: string; + title: string; + price: ProductPrice; + availableForSale: boolean; +} + +interface Product { + id: string; + title: string; + description?: string; + handle: string; + images: { + edges: Array<{ + node: ProductImage; + }>; + }; + priceRange: { + minVariantPrice: ProductPrice; + }; + compareAtPriceRange?: { + minVariantPrice: ProductPrice; + }; + variants: { + edges: Array<{ + node: ProductVariant; + }>; + }; +} + +interface ProductCardProps { + product: Product; +} + +const ProductCard: React.FC = ({ product }) => { + const firstImage = product.images.edges[0]?.node; + const price = product.priceRange.minVariantPrice; + const compareAtPrice = product.compareAtPriceRange?.minVariantPrice; + const hasDiscount = + compareAtPrice && + parseFloat(compareAtPrice.amount) > parseFloat(price.amount); + const firstVariant = product.variants.edges[0]?.node; + const isAvailable = firstVariant?.availableForSale || false; + + const formatPrice = (amount: string) => { + return `$${parseFloat(amount).toFixed(2)}`; + }; + + return ( + + {/* Product Image */} +
+ {firstImage ? ( + {firstImage.altText + ) : ( +
+ +
+ )} + + {hasDiscount && compareAtPrice && ( + + SALE + + )} + + {!isAvailable && ( + + SOLD OUT + + )} +
+ + {/* Product Info */} +
+

+ {truncate(product.title, 65)} +

+ +
+ + {formatPrice(price.amount)} + + {hasDiscount && compareAtPrice && ( + + {formatPrice(compareAtPrice.amount)} + + )} +
+
+ + ); +}; + +export default ProductCard; diff --git a/src/components/shopify/product-detail.tsx b/src/components/shopify/product-detail.tsx new file mode 100644 index 0000000..d117d63 --- /dev/null +++ b/src/components/shopify/product-detail.tsx @@ -0,0 +1,20 @@ +import { useParams } from '@tanstack/react-router'; +import ProductDetail from './product-detail/index'; +import ProductRecommendations from './product-recommendations'; + +export default ProductDetail; + +/** + * Route body for /products/$handle — used as both the route component and its + * pending component, since the skeleton comes from the same in-flight query. + */ +export function ProductPage() { + const { handle } = useParams({ from: '/products/$handle' }); + + return ( + <> + + + + ); +} diff --git a/src/components/shopify/product-detail/index.tsx b/src/components/shopify/product-detail/index.tsx new file mode 100644 index 0000000..ce8dfd5 --- /dev/null +++ b/src/components/shopify/product-detail/index.tsx @@ -0,0 +1,228 @@ +import React, { useState, useEffect } from 'react'; +import { useParams } from '@tanstack/react-router'; +import { useProduct, type Product } from '@/hooks/use-shopify-products'; +import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart'; +import ProductDetailGallery from './product-detail-gallery'; +import ProductDetailInfo from './product-detail-info'; +import { Button } from '@/components/ui/button'; +import { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, +} from '@/components/ui/empty'; + +interface ProductVariant { + id: string; + title: string; + price: { + amount: string; + currencyCode: string; + }; + availableForSale: boolean; + selectedOptions: Array<{ + name: string; + value: string; + }>; + image?: { + url: string; + altText?: string | null; + } | null; +} + +export type { Product }; + +interface ProductDetailProps { + handle?: string; + addToCartLabel?: string; +} + +const ProductDetail: React.FC = ({ + handle: handleProp, + addToCartLabel = 'Add to Cart', +}) => { + const params = useParams({ strict: false }); + const handle = handleProp || params.handle || ''; + const { addItem, openCart, checkoutUrl } = useShopifyCart(); + + const { product, loading, error } = useProduct(handle); + + const [selectedVariant, setSelectedVariant] = useState( + null + ); + const [selectedOptions, setSelectedOptions] = useState< + Record + >({}); + const [quantity, setQuantity] = useState(1); + const [addingToCart, setAddingToCart] = useState(false); + const [buyingNow, setBuyingNow] = useState(false); + + // Initialize variant when product loads + useEffect(() => { + if (product) { + const firstVariant = product.variants.edges[0]?.node; + if (firstVariant) { + setSelectedVariant(firstVariant); + + const initialOptions: Record = {}; + firstVariant.selectedOptions.forEach( + (option: { name: string; value: string }) => { + initialOptions[option.name] = option.value; + } + ); + setSelectedOptions(initialOptions); + } + } + }, [product]); + + // A value is available if some in-stock variant carries it alongside the + // other currently-selected options. Options the shopper hasn't chosen yet + // act as wildcards, so nothing is struck through before a full selection. + const isOptionValueAvailable = (optionName: string, value: string) => { + const variants = product?.variants.edges ?? []; + if (variants.length === 0) return true; + + return variants.some(({ node }) => { + if (!node.availableForSale) return false; + + return node.selectedOptions.every((option) => { + if (option.name === optionName) return option.value === value; + const selected = selectedOptions[option.name]; + return selected === undefined || selected === option.value; + }); + }); + }; + + const handleOptionChange = (optionName: string, value: string) => { + const newOptions = { ...selectedOptions, [optionName]: value }; + setSelectedOptions(newOptions); + + // Find matching variant + const matchingVariant = product?.variants.edges.find(({ node }) => { + return node.selectedOptions.every( + (option) => newOptions[option.name] === option.value + ); + }); + + if (matchingVariant) { + setSelectedVariant(matchingVariant.node); + } + }; + + const handleAddToCart = async () => { + if (!selectedVariant || !product) return; + + try { + setAddingToCart(true); + await addItem(selectedVariant.id, quantity); + openCart(); + } catch (err) { + console.error('Failed to add item to cart:', err); + } finally { + setAddingToCart(false); + } + }; + + // Adds the item, then sends the shopper straight to the Shopify checkout + // (where Shop Pay is offered) rather than opening the cart drawer. + const handleBuyNow = async () => { + if (!selectedVariant || !product) return; + + try { + setBuyingNow(true); + const updatedCart = await addItem(selectedVariant.id, quantity); + const url = updatedCart?.checkoutUrl ?? checkoutUrl; + + if (url) { + redirectToCheckout(url); + } else { + openCart(); + } + } catch (err) { + console.error('Failed to start checkout:', err); + } finally { + setBuyingNow(false); + } + }; + + if (loading) { + return ( +
+
+ {/* Image Gallery Skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ + {/* Product Info Skeleton */} +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !product) { + return ( +
+ + + Product Not Found + + {error || 'The requested product could not be found.'} + + + + + + +
+ ); + } + + return ( +
+
+
+
+ edge.node)} + /> +
+
+ +
+
+
+
+ ); +}; + +export default ProductDetail; diff --git a/src/components/shopify/product-detail/product-detail-gallery.tsx b/src/components/shopify/product-detail/product-detail-gallery.tsx new file mode 100644 index 0000000..f5f1d94 --- /dev/null +++ b/src/components/shopify/product-detail/product-detail-gallery.tsx @@ -0,0 +1,210 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Image from '@/components/ui/image'; +import { RiCloseLine } from '@remixicon/react'; +import { Button } from '@/components/ui/button'; + +interface ProductImage { + url: string; + altText?: string | null; + width?: number | null; + height?: number | null; +} + +interface ProductDetailGalleryProps { + images: ProductImage[]; +} + +const ProductDetailGallery: React.FC = ({ + images, +}) => { + const [zoomedIndex, setZoomedIndex] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const scrollerRef = useRef(null); + const isZoomed = zoomedIndex !== null; + + const close = useCallback(() => setZoomedIndex(null), []); + + // The slide whose left edge sits closest to the scroller's left edge is the + // one in view. Measuring rects keeps this correct whatever the gap or width. + const handleScroll = useCallback(() => { + const scroller = scrollerRef.current; + if (!scroller) return; + + const scrollerLeft = scroller.getBoundingClientRect().left; + let nearest = 0; + let smallestOffset = Infinity; + + Array.from(scroller.children).forEach((child, index) => { + const offset = Math.abs(child.getBoundingClientRect().left - scrollerLeft); + if (offset < smallestOffset) { + smallestOffset = offset; + nearest = index; + } + }); + + setActiveIndex(nearest); + }, []); + + // Touch already scrolls natively; this adds click-and-drag for pointers that + // don't (mouse at mobile widths), suspending snap so the drag stays smooth. + const drag = useRef<{ startX: number; startScroll: number } | null>(null); + + const onPointerDown = (event: React.PointerEvent) => { + if (event.pointerType === 'touch') return; + const scroller = scrollerRef.current; + if (!scroller) return; + + drag.current = { startX: event.clientX, startScroll: scroller.scrollLeft }; + scroller.style.scrollSnapType = 'none'; + }; + + const onPointerMove = (event: React.PointerEvent) => { + const scroller = scrollerRef.current; + if (!drag.current || !scroller) return; + + event.preventDefault(); + scroller.scrollLeft = + drag.current.startScroll - (event.clientX - drag.current.startX); + }; + + const endDrag = () => { + const scroller = scrollerRef.current; + if (!drag.current || !scroller) return; + + drag.current = null; + // Restoring snap lets the browser settle on the nearest slide. + scroller.style.scrollSnapType = ''; + }; + + const scrollToIndex = (index: number) => { + const scroller = scrollerRef.current; + const slide = scroller?.children[index]; + if (!scroller || !slide) return; + + const offset = + slide.getBoundingClientRect().left - scroller.getBoundingClientRect().left; + scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' }); + }; + + // Close on Escape, and keep the page behind the overlay from scrolling. + useEffect(() => { + if (!isZoomed) return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') close(); + }; + + document.addEventListener('keydown', onKeyDown); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', onKeyDown); + document.body.style.overflow = previousOverflow; + }; + }, [isZoomed, close]); + + if (images.length === 0) { + return ( +
+ +
+ ); + } + + const isSingle = images.length === 1; + const zoomedImage = zoomedIndex !== null ? images[zoomedIndex] : null; + + return ( + <> + {/* Swipeable carousel on mobile, grid from sm up */} +
+ {images.map((image, index) => ( + + ))} +
+ + {/* Carousel pagination — the grid needs no dots, so mobile only */} + {!isSingle && ( +
+ {images.map((_, index) => ( +
+ )} + + {zoomedImage && ( +
+ {zoomedImage.altText event.stopPropagation()} + // w/h-auto keeps the box at the image's own ratio; without it the + // width+height attributes make both axes definite and the element + // stretches to the overlay, swallowing backdrop clicks that close it. + className="max-h-full max-w-full w-auto h-auto object-contain" + /> + + +
+ )} + + ); +}; + +export default ProductDetailGallery; diff --git a/src/components/shopify/product-detail/product-detail-info.tsx b/src/components/shopify/product-detail/product-detail-info.tsx new file mode 100644 index 0000000..a41f7d3 --- /dev/null +++ b/src/components/shopify/product-detail/product-detail-info.tsx @@ -0,0 +1,282 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Loader } from '@/components/ui/loader'; +import { RiSubtractLine, RiAddLine } from '@remixicon/react'; +import ShopPayButton from '@/components/shopify/shop-pay-button'; +import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products'; +import { isSwatchOptionName, swatchColorForName } from '@/config/swatches'; +import { isDefaultTitleOption } from '@/services/shopify/catalog'; + +interface ProductPrice { + amount: string; + currencyCode: string; +} + +interface ProductVariant { + id: string; + title: string; + price: ProductPrice; + availableForSale: boolean; + selectedOptions: Array<{ + name: string; + value: string; + }>; +} + +interface Product { + id: string; + title: string; + description?: string; + descriptionHtml?: string; + handle: string; + priceRange: { + minVariantPrice: ProductPrice; + }; + compareAtPriceRange?: { + minVariantPrice: ProductPrice; + }; + options: ProductOption[]; +} + +export interface ProductFeature { + icon: string; + label: string; +} + +interface ProductDetailInfoProps { + product: Product; + selectedVariant: ProductVariant | null; + selectedOptions: Record; + quantity: number; + setQuantity: (quantity: number) => void; + handleAddToCart: () => void; + handleBuyNow?: () => void; + onOptionChange: (optionName: string, value: string) => void; + /** Whether an option value still has an in-stock variant behind it. */ + isOptionValueAvailable?: (optionName: string, value: string) => boolean; + loading?: boolean; + buyingNow?: boolean; + addToCartLabel?: string; +} + +// A swatch comes from the option value's own swatch (colour or image) or the +// colour its name implies (see config/swatches) — never a variant photo. +const swatchStyle = ( + value: ProductOptionValue +): { background?: string; image?: string } => { + if (value.swatch?.color) return { background: value.swatch.color }; + + const swatchImage = value.swatch?.image?.previewImage?.url; + if (swatchImage) return { image: swatchImage }; + + const namedColor = swatchColorForName(value.name); + if (namedColor) return { background: namedColor }; + + return {}; +}; + +const ProductDetailInfo: React.FC = ({ + product, + selectedVariant, + selectedOptions, + quantity, + setQuantity, + handleAddToCart, + handleBuyNow, + onOptionChange, + isOptionValueAvailable, + loading = false, + buyingNow = false, + addToCartLabel = 'Add to Cart', +}) => { + const formatPrice = (amount: string) => { + return `$${parseFloat(amount).toFixed(2)}`; + }; + + const price = selectedVariant?.price || product.priceRange.minVariantPrice; + const compareAtPrice = product.compareAtPriceRange?.minVariantPrice; + const hasDiscount = + compareAtPrice && + parseFloat(compareAtPrice.amount) > parseFloat(price.amount); + const isAvailable = selectedVariant?.availableForSale ?? false; + + const isSwatchOption = (option: ProductOption) => + isSwatchOptionName(option.name); + + // Some products return Size before Color; show the swatches first either way. + // Single-SKU products expose a synthetic `Title: Default Title` option — drop it. + const orderedOptions = [...(product.options ?? [])] + .filter((option) => !isDefaultTitleOption(option)) + .sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a))); + + // `optionValues` carries the swatch data; fall back to plain `values`. + const optionValuesFor = (option: ProductOption): ProductOptionValue[] => + option.optionValues?.length + ? option.optionValues + : option.values.map((value) => ({ id: value, name: value })); + + return ( +
+

+ {product.title} +

+
+ + {formatPrice(price.amount)} + + {hasDiscount && compareAtPrice && ( + + {formatPrice(compareAtPrice.amount)} + + )} +
+ + {/* Product Options — colour swatches lead, whatever order the API returns */} + {orderedOptions.map((option) => { + const isSwatch = isSwatchOption(option); + const selected = selectedOptions[option.name]; + + return ( +
+
+ {option.name} + {isSwatch && selected && ( + : {selected} + )} +
+ +
+ {optionValuesFor(option).map((value) => { + const isSelected = selected === value.name; + const isSoldOut = + isOptionValueAvailable?.(option.name, value.name) === false; + + if (isSwatch) { + const { background, image } = swatchStyle(value); + + return ( + + ); + } + + return ( + + ); + })} +
+
+ ); + })} + + {/* Quantity + Add to Cart */} +
+
+ + + {quantity} + + +
+ + +
+ + {/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */} + + + {/* Description */} + {(product.descriptionHtml || product.description) && ( +
+ {product.descriptionHtml ? ( +
+ ) : ( +

{product.description}

+ )} +
+ )} +
+ ); +}; + +export default ProductDetailInfo; diff --git a/src/components/shopify/product-filters.tsx b/src/components/shopify/product-filters.tsx new file mode 100644 index 0000000..bda1d9c --- /dev/null +++ b/src/components/shopify/product-filters.tsx @@ -0,0 +1,256 @@ +import React, { useState } from 'react'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetBody, + AnimatePresence, +} from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { RiCloseLine, RiCheckLine } from '@remixicon/react'; +import { swatchColorForName } from '@/config/swatches'; +// Shape of a Storefront facet — identical for `search.productFilters` and +// `collection.products.filters`, so both pages share this panel. +export interface ProductFilterValue { + id: string; + label: string; + count: number; + /** JSON string accepted back as a `ProductFilter` input. */ + input: string; +} + +export interface ProductFilterFacet { + id: string; + label: string; + type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN'; + values: ProductFilterValue[]; +} + +interface ProductFiltersProps { + open: boolean; + onOpenChange: (open: boolean) => void; + filters: ProductFilterFacet[]; + activeFilters: string[]; + onActiveFiltersChange: (filters: string[]) => void; +} + +// Colour facets render as swatches; everything else as a labelled list. +const isColorFilter = (filter: ProductFilterFacet) => + /colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color'); + +const ProductFilters: React.FC = ({ + open, + onOpenChange, + filters, + activeFilters, + onActiveFiltersChange, +}) => { + const [priceMin, setPriceMin] = useState(''); + const [priceMax, setPriceMax] = useState(''); + + const listFilters = filters.filter((filter) => filter.type === 'LIST'); + const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE'); + + const activeSet = new Set(activeFilters); + // Price is rebuilt from the inputs rather than toggled, so track it apart. + const activePriceInput = activeFilters.find((input) => + input.includes('"price"') + ); + + const toggleValue = (value: ProductFilterValue) => { + onActiveFiltersChange( + activeSet.has(value.input) + ? activeFilters.filter((input) => input !== value.input) + : [...activeFilters, value.input] + ); + }; + + const applyPrice = () => { + const min = parseFloat(priceMin); + const max = parseFloat(priceMax); + const withoutPrice = activeFilters.filter( + (input) => !input.includes('"price"') + ); + + if (Number.isNaN(min) && Number.isNaN(max)) { + onActiveFiltersChange(withoutPrice); + return; + } + + const price: { min?: number; max?: number } = {}; + if (!Number.isNaN(min)) price.min = min; + if (!Number.isNaN(max)) price.max = max; + + onActiveFiltersChange([...withoutPrice, JSON.stringify({ price })]); + }; + + const clearAll = () => { + setPriceMin(''); + setPriceMax(''); + onActiveFiltersChange([]); + }; + + // Labels for the chips at the top of the panel. + const activeChips = filters + .flatMap((filter) => filter.values) + .filter((value) => activeSet.has(value.input)); + + return ( + + + {open && ( + + +
+ Filters + +
+
+ + + {/* Active selections */} + {(activeChips.length > 0 || activePriceInput) && ( +
+ {activeChips.map((value) => ( + + ))} + +
+ )} + + {filters.length === 0 && ( +

+ No filters available for these results. +

+ )} + + {/* Price */} + {priceFilter && ( +
+

Price

+
+ setPriceMin(event.target.value)} + placeholder="$ From" + aria-label="Minimum price" + className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground" + /> + – + setPriceMax(event.target.value)} + placeholder="$ To" + aria-label="Maximum price" + className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground" + /> + +
+
+ )} + + {/* Facets */} + {listFilters.map((filter) => ( +
+

+ {filter.label} +

+ + {isColorFilter(filter) ? ( +
+ {filter.values.map((value) => { + const isActive = activeSet.has(value.input); + const color = swatchColorForName(value.label); + + return ( + + ); + })} +
+ ) : ( +
+ {filter.values.map((value) => { + const isActive = activeSet.has(value.input); + + return ( + + ); + })} +
+ )} +
+ ))} +
+
+ )} +
+
+ ); +}; + +export default ProductFilters; diff --git a/src/components/shopify/product-recommendations.tsx b/src/components/shopify/product-recommendations.tsx new file mode 100644 index 0000000..2e070f1 --- /dev/null +++ b/src/components/shopify/product-recommendations.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { useParams } from '@tanstack/react-router'; +import { + useProduct, + useProductRecommendations, +} from '@/hooks/use-shopify-products'; +import ProductCard from './product-card'; + +interface ProductRecommendationsProps { + productId?: string; + title?: string; + limit?: number; +} + +const ProductRecommendations: React.FC = ({ + productId: productIdProp, + title = 'You May Also Like', + limit = 4, +}) => { + const handle = useParams({ strict: false }).handle; + const { product } = useProduct(productIdProp ? null : (handle ?? null)); + const resolvedProductId = productIdProp || product?.id || ''; + + const { recommendations, loading, error } = useProductRecommendations( + resolvedProductId || null + ); + + if (!loading && (!recommendations || recommendations.length === 0)) { + return null; + } + + return ( +
+
+

+ {title} +

+ + {error ? ( +

+ Recommendations could not be loaded +

+ ) : ( +
+ {loading + ? Array.from({ length: limit }).map((_, index) => ( +
+
+
+
+
+
+
+ )) + : recommendations + .slice(0, limit) + .map((recommendedProduct) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default ProductRecommendations; diff --git a/src/components/shopify/product-toolbar.tsx b/src/components/shopify/product-toolbar.tsx new file mode 100644 index 0000000..9def1fd --- /dev/null +++ b/src/components/shopify/product-toolbar.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + RiEqualizerLine, + RiArrowDownSLine, + RiCheckLine, +} from '@remixicon/react'; + +export interface ToolbarSortOption { + label: string; +} + +interface ProductToolbarProps { + totalCount?: number | null; + onOpenFilters: () => void; + sortOptions: ToolbarSortOption[]; + sortIndex: number; + onSortChange: (index: number) => void; + activeFilterCount?: number; +} + +// Filters trigger on the left, item count and sort menu on the right — shared +// by the search results and collection pages. +const ProductToolbar: React.FC = ({ + totalCount, + onOpenFilters, + sortOptions, + sortIndex, + onSortChange, + activeFilterCount = 0, +}) => { + const [sortOpen, setSortOpen] = useState(false); + + return ( +
+ + +
+ {typeof totalCount === 'number' && ( + + {totalCount} {totalCount === 1 ? 'Item' : 'Items'} + + )} + +
+ + + {sortOpen && ( + <> + {/* Click-away layer sits under the menu, above the page. */} +
setSortOpen(false)} + /> +
+ {sortOptions.map((option, index) => ( + + ))} +
+ + )} +
+
+
+ ); +}; + +export default ProductToolbar; diff --git a/src/components/shopify/products.tsx b/src/components/shopify/products.tsx new file mode 100644 index 0000000..d26259c --- /dev/null +++ b/src/components/shopify/products.tsx @@ -0,0 +1,140 @@ +import React, { useMemo } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import ProductCard from './product-card'; +import { productsInfiniteQueryOptions } from '@/hooks/use-shopify-products'; +import { Button } from '@/components/ui/button'; +import { Loader } from '@/components/ui/loader'; + +interface ProductsProps { + title?: string; + subtitle?: string; + limit?: number; + showLoadMore?: boolean; +} + +const Products: React.FC = ({ + title = 'Shopify Hydrogen Storefront', + subtitle = 'A Shopify storefront built with Vite, TanStack and Hydrogen.', + limit = 12, + showLoadMore = true, +}) => { + const { + data, + isPending: loading, + error: queryError, + refetch, + fetchNextPage, + hasNextPage: hasMoreProducts, + isFetchingNextPage: loadingMore, + } = useInfiniteQuery(productsInfiniteQueryOptions({ first: limit })); + + const error = queryError + ? queryError.message || 'Failed to load products' + : null; + + // Pages can overlap if the catalogue shifts between requests, so de-dupe. + const products = useMemo(() => { + const seen = new Set(); + return (data?.pages ?? []) + .flatMap((page) => page.products) + .filter((product) => !seen.has(product.id) && seen.add(product.id)); + }, [data]); + + const handleLoadMore = () => { + if (!loadingMore && hasMoreProducts) { + fetchNextPage(); + } + }; + + if (loading) { + return ( +
+
+

+ {title} +

+

+ {subtitle} +

+ +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error || products.length === 0) { + return ( +
+
+

+ {title} +

+

+ {subtitle} +

+

+ {error || + 'Our curated collection is being prepared. Please check back shortly.'} +

+ {error && ( + + )} +
+
+ ); + } + + return ( +
+
+

+ {title} +

+

+ {subtitle} +

+ +
+ {products.map((product) => ( + + ))} +
+ + {showLoadMore && hasMoreProducts && ( +
+ +
+ )} +
+
+ ); +}; + +export default Products; diff --git a/src/components/shopify/search-dialog.tsx b/src/components/shopify/search-dialog.tsx new file mode 100644 index 0000000..2dd70e7 --- /dev/null +++ b/src/components/shopify/search-dialog.tsx @@ -0,0 +1,190 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import Image from '@/components/ui/image'; +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from '@/components/ui/command'; +import { Button } from '@/components/ui/button'; +import { Loader } from '@/components/ui/loader'; +import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react'; +import { searchSuggestionsQueryOptions } from '@/hooks/use-shopify-search'; + +const DEBOUNCE_MS = 250; +const SUGGESTION_COUNT = 3; + +const formatPrice = (amount: string) => `$${parseFloat(amount).toFixed(2)}`; + +const SearchDialog: React.FC = () => { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [term, setTerm] = useState(''); + // The term the suggestions query actually runs with, trailing the input. + const [debouncedTerm, setDebouncedTerm] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedTerm(term.trim()), DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [term]); + + // Query keys are per-term, so a slow early response can never overwrite a + // newer one; the previous results stay up while the next term loads. + const { data, isFetching } = useQuery({ + ...searchSuggestionsQueryOptions(debouncedTerm, SUGGESTION_COUNT), + enabled: Boolean(debouncedTerm), + placeholderData: keepPreviousData, + }); + + const hasQuery = Boolean(term.trim()); + const results = hasQuery && debouncedTerm ? (data?.products ?? []) : []; + const searching = hasQuery && (term.trim() !== debouncedTerm || isFetching); + + const close = () => { + setOpen(false); + setTerm(''); + setDebouncedTerm(''); + }; + + const goToSearchPage = () => { + const query = term.trim(); + close(); + navigate({ to: '/search', search: query ? { q: query } : {} }); + }; + + const goToProduct = (handle: string) => { + close(); + navigate({ to: '/products/$handle', params: { handle } }); + }; + + return ( + <> + + + !next && close()} + title="Search" + description="Search products" + showCloseButton={false} + className="top-24 max-w-xl translate-y-0" + // Results come from the Storefront API, so cmdk must not re-filter them. + shouldFilter={false} + > +
+ { + if (event.key === 'Enter') goToSearchPage(); + }} + /> +
+ {hasQuery && ( + + )} + +
+
+ + {hasQuery && ( + <> +
+ +
+ + + {searching && results.length === 0 ? ( +
+ +
+ ) : results.length === 0 ? ( + No products found. + ) : ( + + {results.map((product) => ( + goToProduct(product.handle)} + className="gap-3 py-2" + > +
+ {product.featuredImage ? ( + {product.featuredImage.altText + ) : ( +
+ +
+ )} +
+
+
+ {product.title} +
+
+ {formatPrice( + product.priceRange.minVariantPrice.amount + )} +
+
+
+ ))} +
+ )} +
+ + {results.length > 0 && ( +
+ +
+ )} + + )} +
+ + ); +}; + +export default SearchDialog; diff --git a/src/components/shopify/search-results.tsx b/src/components/shopify/search-results.tsx new file mode 100644 index 0000000..7a670b0 --- /dev/null +++ b/src/components/shopify/search-results.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import ProductCard from './product-card'; +import ProductFilters from './product-filters'; +import ProductToolbar from './product-toolbar'; +import { Button } from '@/components/ui/button'; +import { Loader } from '@/components/ui/loader'; +import { + searchInfiniteQueryOptions, + type SearchFilter, + type SearchSortKey, +} from '@/hooks/use-shopify-search'; + +const PAGE_SIZE = 24; + +interface SortOption { + label: string; + sortKey: SearchSortKey; + reverse: boolean; +} + +const SORT_OPTIONS: SortOption[] = [ + { label: 'Best Matches', sortKey: 'RELEVANCE', reverse: false }, + { label: 'Price: Low to High', sortKey: 'PRICE', reverse: false }, + { label: 'Price: High to Low', sortKey: 'PRICE', reverse: true }, +]; + +interface SearchResultsProps { + /** The `q` search param; empty shows the whole catalogue. */ + query: string; +} + +const SearchResults: React.FC = ({ query }) => { + const [filters, setFilters] = useState([]); + const [sortIndex, setSortIndex] = useState(0); + const [filtersOpen, setFiltersOpen] = useState(false); + const [activeFilters, setActiveFilters] = useState([]); + + const sort = SORT_OPTIONS[sortIndex]; + + const { + data, + isPending: loading, + error: queryError, + fetchNextPage, + hasNextPage, + isFetchingNextPage: loadingMore, + } = useInfiniteQuery( + searchInfiniteQueryOptions({ + // An empty term still returns the catalogue, which is what an + // unqualified /search visit should show. + query, + first: PAGE_SIZE, + sortKey: sort.sortKey, + reverse: sort.reverse, + filterInputs: activeFilters, + }) + ); + + const firstPage = data?.pages[0]; + const totalCount = firstPage?.totalCount ?? 0; + const error = queryError ? queryError.message || 'Search failed' : null; + + const products = useMemo(() => { + const seen = new Set(); + return (data?.pages ?? []) + .flatMap((page) => page.products) + .filter((product) => !seen.has(product.id) && seen.add(product.id)); + }, [data]); + + // Facet counts change with the result set, but keep the panel stable while + // filters are applied so options don't vanish mid-selection. + useEffect(() => { + if (firstPage && activeFilters.length === 0) setFilters(firstPage.filters); + }, [firstPage, activeFilters.length]); + + const handleLoadMore = () => { + if (!loadingMore && hasNextPage) fetchNextPage(); + }; + + return ( +
+
+

+ Search +

+ +
+ setFiltersOpen(true)} + sortOptions={SORT_OPTIONS} + sortIndex={sortIndex} + onSortChange={setSortIndex} + activeFilterCount={activeFilters.length} + /> +
+ + {/* Results */} +
+ {loading ? ( +
+ {Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : error ? ( +

{error}

+ ) : products.length === 0 ? ( +

+ No products matched{query ? ` “${query}”` : ' your filters'}. +

+ ) : ( + <> +
+ {products.map((product) => ( + + ))} +
+ + {hasNextPage && ( +
+ +
+ )} + + )} +
+
+ + +
+ ); +}; + +export default SearchResults; diff --git a/src/components/shopify/shop-menu.tsx b/src/components/shopify/shop-menu.tsx new file mode 100644 index 0000000..c8ca7f7 --- /dev/null +++ b/src/components/shopify/shop-menu.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useState } from 'react'; +import { Link } from '@tanstack/react-router'; +import { RiArrowDownSLine } from '@remixicon/react'; +import { useCollectionsOnDemand } from '@/hooks/use-shopify-collections'; +import { cn } from '@/lib/utils'; + +interface ShopMenuProps { + label?: string; + /** Renders inline inside the mobile menu instead of as a floating panel. */ + mobile?: boolean; + /** Fires after a collection is picked, so the mobile menu can close itself. */ + onNavigate?: () => void; +} + +const ShopMenu: React.FC = ({ + label = 'Shop', + mobile = false, + onNavigate, +}) => { + const [open, setOpen] = useState(false); + const { collections, loading, error, load } = useCollectionsOnDemand(); + + // The collection list is only worth fetching once someone opens the menu. + const toggle = () => { + const next = !open; + setOpen(next); + if (next) load(); + }; + + const close = () => { + setOpen(false); + onNavigate?.(); + }; + + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open]); + + const itemClasses = cn( + 'block text-sm text-foreground hover:bg-accent transition-colors', + mobile ? 'px-3 py-2' : 'px-4 py-2' + ); + + const body = ( + <> + {loading && + Array.from({ length: 5 }).map((_, index) => ( +
+
+
+ ))} + + {error && ( +
+

{error}

+ +
+ )} + + {!loading && !error && collections.length === 0 && ( +

+ No collections yet. +

+ )} + + {collections.map((collection) => ( + + {collection.title} + + ))} + + {collections.length > 0 && ( + <> +
+ + View all collections + + + )} + + ); + + const trigger = ( + + ); + + if (mobile) { + return ( +
+ {trigger} + {open && ( +
+ {body} +
+ )} +
+ ); + } + + return ( +
+ {trigger} + + {open && ( + <> + {/* Catches the click that dismisses the panel. */} +
setOpen(false)} /> +
+ {body} +
+ + )} +
+ ); +}; + +export default ShopMenu; diff --git a/src/components/shopify/shop-pay-button.tsx b/src/components/shopify/shop-pay-button.tsx new file mode 100644 index 0000000..d8c243f --- /dev/null +++ b/src/components/shopify/shop-pay-button.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/config'; +import ShopPayLogo from '@/components/shopify/shop-pay-logo'; +import { Loader } from '@/components/ui/loader'; +import { Button } from '@/components/ui/button'; + +export interface ShopPayVariant { + id: string; + quantity?: number; +} + +interface ShopPayButtonProps { + variants: ShopPayVariant[]; + disabled?: boolean; + loading?: boolean; + className?: string; + /** Used when no permalink can be built (missing domain or unusable IDs). */ + onFallbackClick?: () => void; +} + +// Cart permalinks need the bare numeric ID; the Storefront API returns GIDs. +function toNumericVariantId(id: string): string | null { + const trimmed = id.trim(); + const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/); + if (gid) return gid[1]; + return /^\d+$/.test(trimmed) ? trimmed : null; +} + +function toStoreUrl(domain?: string): string | null { + if (!domain) return null; + try { + return new URL(domain.startsWith('http') ? domain : `https://${domain}`) + .origin; + } catch { + return null; + } +} + +// https://{shop}/cart/{variantId}:{qty},{variantId}:{qty}?payment=shop_pay +// Loads the cart and drops the buyer straight into the Shop Pay checkout. +export function buildShopPayUrl(variants: ShopPayVariant[]): string | null { + const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN); + if (!storeUrl || variants.length === 0) return null; + + const lines: string[] = []; + for (const { id, quantity = 1 } of variants) { + const numericId = toNumericVariantId(id); + if (!numericId || !Number.isInteger(quantity) || quantity < 1) return null; + lines.push(`${numericId}:${quantity}`); + } + + return `${storeUrl}/cart/${lines.join(',')}?payment=shop_pay`; +} + +const BUTTON_CLASSES = + 'flex h-11 w-full cursor-pointer items-center justify-center rounded-md bg-shop px-4 text-white transition-colors hover:bg-shop/85 disabled:cursor-not-allowed disabled:opacity-50'; + +const ShopPayButton: React.FC = ({ + variants, + disabled = false, + loading = false, + className = '', + onFallbackClick, +}) => { + const shopPayUrl = buildShopPayUrl(variants); + const contents = ( + <> + Buy with + {loading ? : } + + ); + + // An anchor keeps the checkout URL visible, openable in a new tab, and + // navigable without JS; the button only stands in when there's no URL. + if (shopPayUrl && !disabled) { + return ( + + {contents} + + ); + } + + return ( + + ); +}; + +export default ShopPayButton; diff --git a/src/components/shopify/shop-pay-logo.tsx b/src/components/shopify/shop-pay-logo.tsx new file mode 100644 index 0000000..f60455d --- /dev/null +++ b/src/components/shopify/shop-pay-logo.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +// "Buy with shop" lockup — the wordmark and the Shop mark are both in the path +// data, so the button needs no additional text beyond a screen-reader label. +const ShopPayLogo: React.FC<{ className?: string }> = ({ + className = 'h-auto w-[98px]', +}) => ( + +); + +export default ShopPayLogo; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..4d38506 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..556909e --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,193 @@ +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + // Forwarded to the inner Command so callers can drive filtering themselves + // (e.g. results already filtered server-side). + shouldFilter, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean + shouldFilter?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..e5f0a96 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,156 @@ +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx new file mode 100644 index 0000000..64f3428 --- /dev/null +++ b/src/components/ui/empty.tsx @@ -0,0 +1,105 @@ +import React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const emptyMediaVariants = cva( + "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/src/components/ui/image.tsx b/src/components/ui/image.tsx new file mode 100644 index 0000000..1a372f1 --- /dev/null +++ b/src/components/ui/image.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +// A small stand-in for `next/image`, covering the props this storefront uses. +// Shopify's CDN resizes on request via `?width=`, so CDN images get a real +// `srcset` and the browser picks a size from `sizes` — the same job Next's +// image optimiser did, without a server in the loop. + +const WIDTHS = [160, 320, 480, 640, 960, 1280, 1600, 2048]; + +const isShopifyCdn = (src: string) => { + try { + const { hostname } = new URL(src); + return hostname === 'cdn.shopify.com' || hostname.endsWith('.shopify.com'); + } catch { + return false; + } +}; + +const withWidth = (src: string, width: number) => { + const url = new URL(src); + url.searchParams.set('width', String(width)); + return url.toString(); +}; + +type ImageProps = Omit< + React.ImgHTMLAttributes, + 'src' | 'width' | 'height' +> & { + src: string; + alt: string; + width?: number; + height?: number; + /** Absolutely fills the nearest positioned ancestor, like next/image. */ + fill?: boolean; + /** Above-the-fold image: load eagerly at high priority. */ + priority?: boolean; +}; + +export const Image: React.FC = ({ + src, + alt, + width, + height, + fill = false, + priority = false, + sizes, + className, + ...props +}) => { + const resizable = isShopifyCdn(src); + // With `sizes` (or `fill`) the browser picks from a width ladder; a fixed + // thumbnail only needs its own size at 1x and 2x. + const fixedWidth = !fill && !sizes ? width : undefined; + + const srcSet = !resizable + ? undefined + : fixedWidth + ? `${withWidth(src, fixedWidth)} 1x, ${withWidth(src, fixedWidth * 2)} 2x` + : WIDTHS.map((w) => `${withWidth(src, w)} ${w}w`).join(', '); + + return ( + {alt} + ); +}; + +export default Image; diff --git a/src/components/ui/loader.tsx b/src/components/ui/loader.tsx new file mode 100644 index 0000000..131ab8d --- /dev/null +++ b/src/components/ui/loader.tsx @@ -0,0 +1,96 @@ +import { cn } from "@/lib/utils"; +import type { HTMLAttributes } from "react"; + +interface LoaderIconProps { + size?: number; +} + +const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..435880e --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,308 @@ +import React, { useState, useCallback, useContext, createContext } from 'react'; +import { createPortal } from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { cn } from '@/lib/utils'; + +interface SheetContextType { + open: boolean; + setOpen: (open: boolean) => void; + side: 'top' | 'right' | 'bottom' | 'left'; +} + +const SheetContext = createContext(undefined); + +function useSheet() { + const context = useContext(SheetContext); + if (!context) { + throw new Error('Sheet components must be used within a Sheet'); + } + return context; +} + +interface SheetProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + children: React.ReactNode; + side?: 'top' | 'right' | 'bottom' | 'left'; +} + +function Sheet({ + open: controlledOpen, + onOpenChange, + children, + side = 'right', +}: SheetProps) { + const [internalOpen, setInternalOpen] = useState(false); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + + const setOpen = useCallback( + (newOpen: boolean) => { + if (!isControlled) { + setInternalOpen(newOpen); + } + onOpenChange?.(newOpen); + }, + [isControlled, onOpenChange] + ); + + return ( + + {children} + + ); +} + +function SheetTrigger( + props: React.ButtonHTMLAttributes & { asChild?: boolean } +) { + const { setOpen } = useSheet(); + const { children, asChild, ...rest } = props; + + if (asChild && React.isValidElement(children)) { + const child = children as React.ReactElement; + return React.cloneElement(child, { + ...rest, + onClick: (e: React.MouseEvent) => { + setOpen(true); + child.props.onClick?.(e); + }, + } as any); + } + + return ( + + ); +} + +function SheetPortal({ children }: { children: React.ReactNode }) { + return createPortal(children, document.body); +} + +function SheetOverlay({ + className, + onClick, + ...props +}: React.HTMLAttributes) { + const { setOpen } = useSheet(); + + return ( + { + setOpen(false); + onClick?.(e); + }} + {...(props as any)} + /> + ); +} + +interface SheetContentProps extends React.HTMLAttributes { + showCloseButton?: boolean; +} + +function SheetContent({ + className, + children, + showCloseButton = true, + ...props +}: SheetContentProps) { + const { setOpen, side } = useSheet(); + + const sideClasses = { + right: 'inset-y-0 right-0 h-full w-3/4 sm:max-w-sm border-l', + left: 'inset-y-0 left-0 h-full w-3/4 sm:max-w-sm border-r', + top: 'inset-x-0 top-0 h-auto border-b', + bottom: 'inset-x-0 bottom-0 h-auto border-t', + }; + + const slideVariants = { + right: { + initial: { x: 400, opacity: 0 }, + animate: { x: 0, opacity: 1 }, + exit: { x: 400, opacity: 0 }, + }, + left: { + initial: { x: -400, opacity: 0 }, + animate: { x: 0, opacity: 1 }, + exit: { x: -400, opacity: 0 }, + }, + top: { + initial: { y: -400, opacity: 0 }, + animate: { y: 0, opacity: 1 }, + exit: { y: -400, opacity: 0 }, + }, + bottom: { + initial: { y: 400, opacity: 0 }, + animate: { y: 0, opacity: 1 }, + exit: { y: 400, opacity: 0 }, + }, + }; + + return ( + + + + {children} + {showCloseButton && ( + + )} + + + ); +} + +function SheetClose( + props: React.ButtonHTMLAttributes & { asChild?: boolean } +) { + const { setOpen } = useSheet(); + const { children, asChild, ...rest } = props; + + if (asChild && React.isValidElement(children)) { + const child = children as React.ReactElement; + return React.cloneElement(child, { + ...rest, + onClick: (e: React.MouseEvent) => { + setOpen(false); + child.props.onClick?.(e); + }, + } as any); + } + + return ( + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function SheetTitle({ className, ...props }: React.ComponentProps<'h2'>) { + return ( +

+ ); +} + +function SheetDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( +

+ ); +} + +interface SheetBodyProps extends React.HTMLAttributes { + children: React.ReactNode; +} + +function SheetBody({ className, children, ...props }: SheetBodyProps) { + return ( +

+ {children} +
+ ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, + SheetBody, + SheetPortal, + SheetOverlay, + AnimatePresence, +}; diff --git a/src/config/site.ts b/src/config/site.ts new file mode 100644 index 0000000..3edf261 --- /dev/null +++ b/src/config/site.ts @@ -0,0 +1,19 @@ +/** Chrome shared by every route — rendered once in the root layout. */ +export const site = { + storeName: 'Shop', + description: 'A Shopify storefront built with Vite, TanStack and Hydrogen.', + // Absolute origin for canonical and Open Graph URLs. + url: + import.meta.env.VITE_SITE_URL ?? + (typeof window !== 'undefined' + ? window.location.origin + : 'http://localhost:5173'), + logoUrl: '', + copyright: '© 2026 Shop. All rights reserved.', + // Literal paths (not `string`), so the router's typed links accept them. + // Kept free of router imports: the route tree imports this file. + navLinks: [ + { label: 'Products', url: '/' }, + { label: 'Collections', url: '/collections' }, + ] as const, +}; diff --git a/src/config/swatches.ts b/src/config/swatches.ts new file mode 100644 index 0000000..85925ab --- /dev/null +++ b/src/config/swatches.ts @@ -0,0 +1,51 @@ +// Swatch configuration for product option pickers. +// +// Shopify exposes a `swatch` on each option value, but plenty of products ship +// without one configured. These settings decide which options render as colour +// circles and what colour to use when the API doesn't supply one. + +// Option names (case-insensitive) rendered as colour circles instead of pills. +export const SWATCH_OPTION_NAMES = ['color', 'colour']; + +// Fallback colours, keyed by lowercased option value name. The first group +// mirrors hexes the store defines on other products, so a product missing its +// swatches still matches the ones that have them. Add your own names here. +export const SWATCH_COLORS: Record = { + clay: '#5a4b3c', + green: '#81a69b', + ocean: '#768da0', + olive: '#7f8060', + purple: '#766589', + red: '#a88084', + + beige: '#e8dcc8', + black: '#000000', + blue: '#2563eb', + brown: '#7c5c40', + burgundy: '#6d2532', + charcoal: '#36393d', + cream: '#f3ead8', + gold: '#c9a227', + gray: '#8a8d91', + grey: '#8a8d91', + ivory: '#f6f2e6', + khaki: '#a89d78', + lavender: '#b9a8d0', + mint: '#a8d5ba', + navy: '#1f2a44', + orange: '#e2733a', + pink: '#e3a7b5', + sand: '#d9c9a8', + silver: '#c4c6c8', + stone: '#a9a294', + tan: '#c8a882', + teal: '#3f8f8b', + white: '#ffffff', + yellow: '#e5c34a', +}; + +export const swatchColorForName = (name: string): string | undefined => + SWATCH_COLORS[name.trim().toLowerCase()]; + +export const isSwatchOptionName = (name: string): boolean => + SWATCH_OPTION_NAMES.includes(name.trim().toLowerCase()); diff --git a/src/graphql/cart.ts b/src/graphql/cart.ts new file mode 100644 index 0000000..d95959d --- /dev/null +++ b/src/graphql/cart.ts @@ -0,0 +1,177 @@ +import { gql } from '@shopify/hydrogen'; + +// Cart Fragment for consistent cart data +const CartFragment = gql(` + fragment CartFragment on Cart { + id + checkoutUrl + totalQuantity + discountCodes { + code + applicable + } + cost { + subtotalAmount { + amount + currencyCode + } + totalAmount { + amount + currencyCode + } + totalTaxAmount { + amount + currencyCode + } + } + lines(first: 100) { + edges { + node { + id + quantity + cost { + totalAmount { + amount + currencyCode + } + } + merchandise { + ... on ProductVariant { + id + title + selectedOptions { + name + value + } + price { + amount + currencyCode + } + image { + id + url + altText + width + height + } + product { + id + title + handle + vendor + } + } + } + } + } + } + } +`); + +// Create a new cart +export const CREATE_CART_MUTATION = gql( + ` + mutation CreateCart($lines: [CartLineInput!], $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + cartCreate(input: { lines: $lines }) { + cart { + ...CartFragment + } + userErrors { + field + message + } + } + } +`, + [CartFragment] +); + +// Add lines to cart +export const ADD_CART_LINES_MUTATION = gql( + ` + mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + cartLinesAdd(cartId: $cartId, lines: $lines) { + cart { + ...CartFragment + } + userErrors { + field + message + } + } + } +`, + [CartFragment] +); + +// Update cart lines +export const UPDATE_CART_LINES_MUTATION = gql( + ` + mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + cartLinesUpdate(cartId: $cartId, lines: $lines) { + cart { + ...CartFragment + } + userErrors { + field + message + } + } + } +`, + [CartFragment] +); + +// Remove lines from cart +export const REMOVE_CART_LINES_MUTATION = gql( + ` + mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { + cart { + ...CartFragment + } + userErrors { + field + message + } + } + } +`, + [CartFragment] +); + +// Apply (or clear) discount codes on the cart +export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql( + ` + mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) { + cart { + ...CartFragment + } + userErrors { + field + message + } + } + } +`, + [CartFragment] +); + +// Get cart by ID +export const GET_CART_QUERY = gql( + ` + query GetCart($cartId: ID!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + cart(id: $cartId) { + ...CartFragment + } + } +`, + [CartFragment] +); diff --git a/src/graphql/collections.ts b/src/graphql/collections.ts new file mode 100644 index 0000000..6523afc --- /dev/null +++ b/src/graphql/collections.ts @@ -0,0 +1,91 @@ +import { gql } from '@shopify/hydrogen'; +import { ProductFragment } from '@/graphql/products'; + +// Get all collections +export const GET_COLLECTIONS_QUERY = gql(` + query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + collections(first: $first) { + edges { + node { + id + title + handle + description + descriptionHtml + image { + id + url + altText + width + height + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`); + +// Get products in a collection +export const GET_COLLECTION_PRODUCTS_QUERY = gql( + ` + query GetCollectionProducts( + $handle: String! + $first: Int! + $after: String + $sortKey: ProductCollectionSortKeys + $reverse: Boolean + $filters: [ProductFilter!] + $country: CountryCode + $language: LanguageCode + ) @inContext(country: $country, language: $language) { + collection(handle: $handle) { + id + title + handle + description + descriptionHtml + image { + id + url + altText + width + height + } + products( + first: $first + after: $after + sortKey: $sortKey + reverse: $reverse + filters: $filters + ) { + filters { + id + label + type + values { + id + label + count + input + } + } + edges { + node { + ...ProductFragment + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +`, + [ProductFragment] +); diff --git a/src/graphql/customer.ts b/src/graphql/customer.ts new file mode 100644 index 0000000..f592b25 --- /dev/null +++ b/src/graphql/customer.ts @@ -0,0 +1,178 @@ +// Customer account operations (classic Storefront customer accounts). +// https://shopify.dev/docs/api/storefront/latest/objects/Customer +import { gql } from '@shopify/hydrogen'; + +const CustomerFragment = gql(` + fragment CustomerFragment on Customer { + id + email + firstName + lastName + phone + displayName + acceptsMarketing + createdAt + defaultAddress { + id + firstName + lastName + address1 + address2 + city + province + zip + country + phone + } + } +`); + +export const CUSTOMER_QUERY = gql( + ` + query GetCustomer($customerAccessToken: String!, $orderCount: Int!) { + customer(customerAccessToken: $customerAccessToken) { + ...CustomerFragment + orders(first: $orderCount, reverse: true) { + edges { + node { + id + orderNumber + processedAt + financialStatus + fulfillmentStatus + statusUrl + currentTotalPrice { + amount + currencyCode + } + lineItems(first: 5) { + edges { + node { + title + quantity + variant { + image { + url + altText + } + } + } + } + } + } + } + } + } + } +`, + [CustomerFragment] +); + +export const CUSTOMER_CREATE_MUTATION = gql(` + mutation CustomerCreate($input: CustomerCreateInput!) { + customerCreate(input: $input) { + customer { + id + email + } + customerUserErrors { + code + field + message + } + } + } +`); + +export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = gql(` + mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) { + customerAccessTokenCreate(input: $input) { + customerAccessToken { + accessToken + expiresAt + } + customerUserErrors { + code + field + message + } + } + } +`); + +export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = gql(` + mutation CustomerAccessTokenDelete($customerAccessToken: String!) { + customerAccessTokenDelete(customerAccessToken: $customerAccessToken) { + deletedAccessToken + userErrors { + field + message + } + } + } +`); + +// Sends the "reset your password" email. +export const CUSTOMER_RECOVER_MUTATION = gql(` + mutation CustomerRecover($email: String!) { + customerRecover(email: $email) { + customerUserErrors { + code + field + message + } + } + } +`); + +// Completes the reset using the id + token from the emailed link. +export const CUSTOMER_RESET_MUTATION = gql(` + mutation CustomerReset($id: ID!, $input: CustomerResetInput!) { + customerReset(id: $id, input: $input) { + customerAccessToken { + accessToken + expiresAt + } + customerUserErrors { + code + field + message + } + } + } +`); + +// Activation link sent to customers created by the merchant. +export const CUSTOMER_ACTIVATE_MUTATION = gql(` + mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) { + customerActivate(id: $id, input: $input) { + customerAccessToken { + accessToken + expiresAt + } + customerUserErrors { + code + field + message + } + } + } +`); + +export const CUSTOMER_UPDATE_MUTATION = gql( + ` + mutation CustomerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) { + customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) { + customer { + ...CustomerFragment + } + customerUserErrors { + code + field + message + } + } + } +`, + [CustomerFragment] +); diff --git a/src/graphql/policies.ts b/src/graphql/policies.ts new file mode 100644 index 0000000..c2b710b --- /dev/null +++ b/src/graphql/policies.ts @@ -0,0 +1,45 @@ +import { gql } from '@shopify/hydrogen'; + +// Shop policies are exposed on the `shop` object of the Storefront API. +// There is no lookup-by-handle field, so we fetch all of them and match. +export const GET_SHOP_POLICIES_QUERY = gql(` + query GetShopPolicies { + shop { + privacyPolicy { + id + title + handle + body + url + } + termsOfService { + id + title + handle + body + url + } + refundPolicy { + id + title + handle + body + url + } + shippingPolicy { + id + title + handle + body + url + } + subscriptionPolicy { + id + title + handle + body + url + } + } + } +`); diff --git a/src/graphql/products.ts b/src/graphql/products.ts new file mode 100644 index 0000000..7423c05 --- /dev/null +++ b/src/graphql/products.ts @@ -0,0 +1,149 @@ +import { gql } from '@shopify/hydrogen'; + +// Product Fragment for consistent product data +export const ProductFragment = gql(` + fragment ProductFragment on Product { + id + title + handle + description + descriptionHtml + vendor + productType + tags + availableForSale + priceRange { + minVariantPrice { + amount + currencyCode + } + maxVariantPrice { + amount + currencyCode + } + } + compareAtPriceRange { + minVariantPrice { + amount + currencyCode + } + maxVariantPrice { + amount + currencyCode + } + } + images(first: 10) { + edges { + node { + id + url + altText + width + height + } + } + } + variants(first: 100) { + edges { + node { + id + title + availableForSale + selectedOptions { + name + value + } + price { + amount + currencyCode + } + compareAtPrice { + amount + currencyCode + } + image { + id + url + altText + width + height + } + } + } + } + options { + id + name + values + optionValues { + id + name + swatch { + color + image { + previewImage { + url + } + } + } + firstSelectableVariant { + id + image { + id + url + altText + width + height + } + } + } + } + } +`); + +// Get multiple products +export const GET_PRODUCTS_QUERY = gql( + ` + query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) { + edges { + node { + ...ProductFragment + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`, + [ProductFragment] +); + +// Get a single product by handle +export const GET_PRODUCT_QUERY = gql( + ` + query GetProduct($handle: String!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + product(handle: $handle) { + ...ProductFragment + } + } +`, + [ProductFragment] +); + +// Get product recommendations +export const QUERY_PRODUCT_RECOMMENDATIONS = gql( + ` + query GetProductRecommendations($productId: ID!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + productRecommendations(productId: $productId) { + ...ProductFragment + } + } +`, + [ProductFragment] +); diff --git a/src/graphql/search.ts b/src/graphql/search.ts new file mode 100644 index 0000000..f247b2d --- /dev/null +++ b/src/graphql/search.ts @@ -0,0 +1,89 @@ +import { gql } from '@shopify/hydrogen'; +import { ProductFragment } from '@/graphql/products'; + +// Storefront search over products. `productFilters` accepts the raw `input` +// values returned in `productFilters[].values[].input`, so facets round-trip +// without the client needing to know each filter's shape. +export const SEARCH_PRODUCTS_QUERY = gql( + ` + query SearchProducts( + $query: String! + $first: Int! + $after: String + $sortKey: SearchSortKeys + $reverse: Boolean + $productFilters: [ProductFilter!] + $country: CountryCode + $language: LanguageCode + ) @inContext(country: $country, language: $language) { + search( + query: $query + first: $first + after: $after + types: PRODUCT + sortKey: $sortKey + reverse: $reverse + productFilters: $productFilters + ) { + totalCount + pageInfo { + hasNextPage + endCursor + } + productFilters { + id + label + type + values { + id + label + count + input + } + } + edges { + node { + # types: PRODUCT already narrows the results, but the schema still + # types nodes as a union — __typename lets callers narrow too. + __typename + ... on Product { + ...ProductFragment + } + } + } + } + } +`, + [ProductFragment] +); + +// Lightweight variant for the autocomplete dropdown — just enough to render a +// row, so the dialog stays responsive while typing. +export const SEARCH_SUGGESTIONS_QUERY = gql(` + query SearchSuggestions($query: String!, $first: Int!, $country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + search(query: $query, first: $first, types: PRODUCT) { + totalCount + edges { + node { + __typename + ... on Product { + id + title + handle + featuredImage { + url + altText + } + priceRange { + minVariantPrice { + amount + currencyCode + } + } + } + } + } + } + } +`); diff --git a/src/hooks/use-account.ts b/src/hooks/use-account.ts new file mode 100644 index 0000000..aa117d3 --- /dev/null +++ b/src/hooks/use-account.ts @@ -0,0 +1,41 @@ +import { queryOptions } from '@tanstack/react-query'; +import type { Customer } from '@/services/shopify/customer'; + +// The customer token lives in an httpOnly cookie, so account state always comes +// from the Hono API (server/account.ts) rather than from Shopify directly. + +export interface SessionCustomer { + displayName: string; + email: string; + firstName?: string | null; +} + +async function getJson(url: string): Promise { + const response = await fetch(url, { credentials: 'same-origin' }); + if (!response.ok) throw new Error(`Request failed: ${response.status}`); + return response.json(); +} + +/** Every account query shares this prefix, so sign-in/out can reset them all. */ +export const ACCOUNT_QUERY_KEY = ['account'] as const; + +export const sessionQueryOptions = () => + queryOptions({ + queryKey: [...ACCOUNT_QUERY_KEY, 'me'], + queryFn: () => + getJson<{ customer: SessionCustomer | null }>('/api/account/me') + .then((data) => data.customer) + // A failed probe reads as signed out. + .catch(() => null), + }); + +export const customerQueryOptions = () => + queryOptions({ + queryKey: [...ACCOUNT_QUERY_KEY, 'customer'], + queryFn: () => + getJson<{ customer: Customer | null }>('/api/account/customer').then( + (data) => data.customer + ), + // Orders should reflect a purchase made a moment ago. + staleTime: 0, + }); diff --git a/src/hooks/use-shopify-cart.ts b/src/hooks/use-shopify-cart.ts new file mode 100644 index 0000000..c5e2091 --- /dev/null +++ b/src/hooks/use-shopify-cart.ts @@ -0,0 +1,440 @@ +import { create } from 'zustand'; +import { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; +import { + CREATE_CART_MUTATION, + ADD_CART_LINES_MUTATION, + UPDATE_CART_LINES_MUTATION, + REMOVE_CART_LINES_MUTATION, + UPDATE_CART_DISCOUNT_CODES_MUTATION, + GET_CART_QUERY, +} from '@/graphql/cart'; +import { useEffect } from 'react'; + +// ─── Types ─────────────────────────────────────────────────────────── + +export interface CartLineInput { + merchandiseId: string; + quantity: number; +} + +export interface CartLineUpdateInput { + id: string; + quantity: number; +} + +interface CartLine { + id: string; + quantity: number; + cost: { + totalAmount: { + amount: string; + currencyCode: string; + }; + }; + merchandise: { + id: string; + title: string; + selectedOptions: Array<{ + name: string; + value: string; + }>; + price: { + amount: string; + currencyCode: string; + }; + image?: { + id?: string | null; + url: string; + altText?: string | null; + width?: number | null; + height?: number | null; + } | null; + product: { + id: string; + title: string; + handle: string; + vendor?: string; + }; + }; +} + +export interface CartDiscountCode { + code: string; + applicable: boolean; +} + +export interface Cart { + id: string; + checkoutUrl: string; + totalQuantity: number; + discountCodes?: CartDiscountCode[]; + cost: { + subtotalAmount: { + amount: string; + currencyCode: string; + }; + totalAmount: { + amount: string; + currencyCode: string; + }; + totalTaxAmount?: { + amount: string; + currencyCode: string; + } | null; + }; + lines: { + edges: Array<{ + node: CartLine; + }>; + }; +} + +// ─── Shopify API functions ─────────────────────────────────────────── + +/** + * Every cart mutation returns the same `{ cart, userErrors }` payload, and both + * the payload and the cart inside it are nullable — Shopify returns no cart when + * the mutation could not be applied. Callers want a cart or an exception. + */ +function unwrapCartPayload( + payload: + | { + cart?: T | null; + userErrors: ReadonlyArray<{ message: string }>; + } + | null + | undefined +): T { + if (payload?.userErrors.length) { + throw new Error(payload.userErrors[0].message); + } + + if (!payload?.cart) { + throw new Error('Cart update failed. Please try again.'); + } + + return payload.cart; +} + +async function createCartApi(lines: CartLineInput[] = []): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(CREATE_CART_MUTATION, { + variables: { lines: lines.length > 0 ? lines : null }, + }), + 'CreateCart' + ); + + return unwrapCartPayload(data.cartCreate); +} + +async function addCartLinesApi( + cartId: string, + lines: CartLineInput[] +): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(ADD_CART_LINES_MUTATION, { + variables: { cartId, lines }, + }), + 'AddCartLines' + ); + + return unwrapCartPayload(data.cartLinesAdd); +} + +async function updateCartLinesApi( + cartId: string, + lines: CartLineUpdateInput[] +): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(UPDATE_CART_LINES_MUTATION, { + variables: { cartId, lines }, + }), + 'UpdateCartLines' + ); + + return unwrapCartPayload(data.cartLinesUpdate); +} + +async function removeCartLinesApi( + cartId: string, + lineIds: string[] +): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(REMOVE_CART_LINES_MUTATION, { + variables: { cartId, lineIds }, + }), + 'RemoveCartLines' + ); + + return unwrapCartPayload(data.cartLinesRemove); +} + +async function updateCartDiscountCodesApi( + cartId: string, + discountCodes: string[] +): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(UPDATE_CART_DISCOUNT_CODES_MUTATION, { + variables: { cartId, discountCodes }, + }), + 'UpdateCartDiscountCodes' + ); + + return unwrapCartPayload(data.cartDiscountCodesUpdate); +} + +async function getCartApi(cartId: string): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(GET_CART_QUERY, { variables: { cartId } }), + 'GetCart' + ); + + return data.cart; +} + +export function redirectToCheckout(checkoutUrl: string): void { + if (checkoutUrl) { + window.location.href = checkoutUrl; + } +} + +// ─── Zustand Store ─────────────────────────────────────────────────── + +const CART_ID_KEY = 'cartId'; + +interface CartState { + // State + isOpen: boolean; + cartId: string | null; + cart: Cart | null; + loading: boolean; + error: string | null; + _initialized: boolean; + + // Computed (derived in the hook) + // items, itemCount, totalAmount, checkoutUrl + + // Actions + openCart: () => void; + closeCart: () => void; + toggleCart: () => void; + initCart: () => void; + addItem: (variantId: string, quantity?: number) => Promise; + removeItem: (lineId: string) => Promise; + updateItemQuantity: (lineId: string, quantity: number) => Promise; + applyDiscountCode: (code: string) => Promise; + refreshCart: () => Promise; +} + +export const useCartStore = create((set, get) => ({ + // Initial state + isOpen: false, + cartId: null, + cart: null, + loading: true, + error: null, + _initialized: false, + + // UI actions + openCart: () => set({ isOpen: true }), + closeCart: () => set({ isOpen: false }), + toggleCart: () => set((s) => ({ isOpen: !s.isOpen })), + + // Initialize cart from localStorage + initCart: () => { + if (get()._initialized) return; + set({ _initialized: true }); + + if (typeof window === 'undefined') return; + + const storedCartId = localStorage.getItem(CART_ID_KEY); + if (!storedCartId) { + set({ loading: false }); + return; + } + + set({ loading: true }); + getCartApi(storedCartId) + .then((fetchedCart) => { + if (fetchedCart) { + set({ cart: fetchedCart, cartId: storedCartId, loading: false }); + } else { + localStorage.removeItem(CART_ID_KEY); + set({ cartId: null, cart: null, loading: false }); + } + }) + .catch(() => { + localStorage.removeItem(CART_ID_KEY); + set({ + cartId: null, + cart: null, + loading: false, + error: 'Failed to fetch cart', + }); + }); + }, + + // Add item to cart (creates cart if needed) + addItem: async (variantId: string, quantity: number = 1) => { + try { + set({ loading: true, error: null }); + + // Get or create cart + let currentCartId = get().cartId; + if (!currentCartId) { + const storedCartId = localStorage.getItem(CART_ID_KEY); + if (storedCartId) { + currentCartId = storedCartId; + set({ cartId: storedCartId }); + } else { + const newCart = await createCartApi(); + localStorage.setItem(CART_ID_KEY, newCart.id); + set({ cart: newCart, cartId: newCart.id }); + currentCartId = newCart.id; + } + } + + const updatedCart = await addCartLinesApi(currentCartId, [ + { merchandiseId: variantId, quantity }, + ]); + set({ cart: updatedCart, loading: false }); + return updatedCart; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to add item to cart'; + set({ error: errorMessage, loading: false }); + throw err; + } + }, + + // Remove item from cart + removeItem: async (lineId: string) => { + const { cartId } = get(); + if (!cartId) throw new Error('No cart exists'); + + try { + set({ loading: true, error: null }); + const updatedCart = await removeCartLinesApi(cartId, [lineId]); + set({ cart: updatedCart, loading: false }); + return updatedCart; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to remove item from cart'; + set({ error: errorMessage, loading: false }); + throw err; + } + }, + + // Update item quantity + updateItemQuantity: async (lineId: string, quantity: number) => { + const { cartId, removeItem } = get(); + if (!cartId) throw new Error('No cart exists'); + + if (quantity <= 0) { + return removeItem(lineId); + } + + try { + set({ loading: true, error: null }); + const updatedCart = await updateCartLinesApi(cartId, [ + { id: lineId, quantity }, + ]); + set({ cart: updatedCart, loading: false }); + return updatedCart; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to update item quantity'; + set({ error: errorMessage, loading: false }); + throw err; + } + }, + + // Apply a discount code. Shopify accepts unknown codes and reports them back + // as `applicable: false`, so callers should check the returned cart. + applyDiscountCode: async (code: string) => { + const { cartId } = get(); + if (!cartId) throw new Error('No cart exists'); + + try { + set({ loading: true, error: null }); + const trimmed = code.trim(); + const updatedCart = await updateCartDiscountCodesApi( + cartId, + trimmed ? [trimmed] : [] + ); + set({ cart: updatedCart, loading: false }); + return updatedCart; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to apply discount code'; + set({ error: errorMessage, loading: false }); + throw err; + } + }, + + // Refresh cart from Shopify + refreshCart: async () => { + const storedCartId = localStorage.getItem(CART_ID_KEY); + if (!storedCartId) { + set({ loading: false }); + return; + } + + try { + set({ loading: true, error: null }); + const fetchedCart = await getCartApi(storedCartId); + if (fetchedCart) { + set({ cart: fetchedCart, cartId: storedCartId, loading: false }); + } else { + localStorage.removeItem(CART_ID_KEY); + set({ cartId: null, cart: null, loading: false }); + } + } catch (err) { + localStorage.removeItem(CART_ID_KEY); + set({ + cartId: null, + cart: null, + loading: false, + error: 'Failed to fetch cart', + }); + } + }, +})); + +// ─── Hook (backwards-compatible API) ───────────────────────────────── + +export function useShopifyCart() { + const store = useCartStore(); + + // Initialize cart safely in an effect, not during render + useEffect(() => { + if (!store._initialized) { + store.initCart(); + } + }, []); + + const items = store.cart?.lines?.edges?.map((edge) => edge.node) ?? []; + const itemCount = items.reduce((sum, item) => sum + item.quantity, 0); + const totalAmount = parseFloat(store.cart?.cost?.totalAmount?.amount ?? '0'); + const checkoutUrl = store.cart?.checkoutUrl ?? null; + + return { + isOpen: store.isOpen, + openCart: store.openCart, + closeCart: store.closeCart, + toggleCart: store.toggleCart, + cartId: store.cartId, + cart: store.cart, + items, + itemCount, + totalAmount, + checkoutUrl, + loading: store.loading, + error: store.error, + addItem: store.addItem, + removeItem: store.removeItem, + updateItemQuantity: store.updateItemQuantity, + applyDiscountCode: store.applyDiscountCode, + refreshCart: store.refreshCart, + }; +} diff --git a/src/hooks/use-shopify-collections.ts b/src/hooks/use-shopify-collections.ts new file mode 100644 index 0000000..cb3c2ef --- /dev/null +++ b/src/hooks/use-shopify-collections.ts @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { + infiniteQueryOptions, + queryOptions, + useQuery, +} from '@tanstack/react-query'; +import { + getCollections, + getCollectionProductsPage, + type CollectionSortKey, +} from '@/services/shopify/catalog'; + +export { + getCollections, + getCollectionProducts, + getCollectionProductsPage, +} from '@/services/shopify/catalog'; +export type { + Collection, + CollectionWithProducts, + CollectionSortKey, + CollectionProductsPage, + ProductFilterFacet, +} from '@/services/shopify/catalog'; + +export const collectionsQueryOptions = (first = 50) => + queryOptions({ + queryKey: ['collections', first], + queryFn: () => getCollections(first), + }); + +export const collectionProductsInfiniteQueryOptions = ( + handle: string, + { + first = 24, + sortKey = 'COLLECTION_DEFAULT', + reverse = false, + filterInputs = [], + }: { + first?: number; + sortKey?: CollectionSortKey; + reverse?: boolean; + filterInputs?: string[]; + } = {} +) => + infiniteQueryOptions({ + queryKey: [ + 'collection-products', + handle, + { first, sortKey, reverse, filterInputs }, + ], + queryFn: ({ pageParam }) => + getCollectionProductsPage(handle, { + first, + after: pageParam, + sortKey, + reverse, + filterInputs, + }), + initialPageParam: null as string | null, + getNextPageParam: (page) => (page.hasNextPage ? page.endCursor : undefined), + }); + +// Hook for fetching all collections +export function useCollections(first = 50) { + const { data, isPending, error, refetch } = useQuery( + collectionsQueryOptions(first) + ); + + return { + collections: data ?? [], + loading: isPending, + error: error ? error.message || 'Failed to load collections' : null, + refetch, + }; +} + +// Deferred variant of useCollections: nothing is requested until `load` runs, +// so a menu can hold off until it's actually opened. Re-opening reuses the +// cached result; after a failure, `load` retries. +export function useCollectionsOnDemand(first = 50) { + const [enabled, setEnabled] = useState(false); + const { data, isFetching, error, refetch } = useQuery({ + ...collectionsQueryOptions(first), + enabled, + }); + + const load = () => { + if (!enabled) setEnabled(true); + else if (error) refetch(); + }; + + return { + collections: data ?? [], + loading: isFetching && !data, + error: error ? error.message || 'Failed to load collections' : null, + load, + }; +} diff --git a/src/hooks/use-shopify-policies.ts b/src/hooks/use-shopify-policies.ts new file mode 100644 index 0000000..20b3cb5 --- /dev/null +++ b/src/hooks/use-shopify-policies.ts @@ -0,0 +1,46 @@ +import { queryOptions } from '@tanstack/react-query'; +import { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; +import { GET_SHOP_POLICIES_QUERY } from '@/graphql/policies'; + +export interface ShopPolicy { + id: string; + title: string; + handle: string; + body: string; + url: string; +} + +// Handles Shopify uses for each policy — also the routes under /policies/$handle. +export const POLICY_HANDLES = [ + 'terms-of-service', + 'privacy-policy', + 'refund-policy', + 'shipping-policy', + 'subscription-policy', +] as const; + +export async function getShopPolicies(): Promise { + try { + const data = unwrapStorefrontResult( + await storefront.graphql(GET_SHOP_POLICIES_QUERY), + 'GetShopPolicies' + ); + + return Object.values(data.shop ?? {}).filter( + (policy): policy is ShopPolicy => Boolean(policy?.handle) + ); + } catch (err) { + // A storefront without policies configured shouldn't break the page. + console.error('Failed to load shop policies:', err); + return []; + } +} + +// Policies change rarely, so they stay fresh for an hour rather than the +// app-wide default. +export const shopPoliciesQueryOptions = () => + queryOptions({ + queryKey: ['shop-policies'], + queryFn: getShopPolicies, + staleTime: 60 * 60 * 1000, + }); diff --git a/src/hooks/use-shopify-products.ts b/src/hooks/use-shopify-products.ts new file mode 100644 index 0000000..e98a5bf --- /dev/null +++ b/src/hooks/use-shopify-products.ts @@ -0,0 +1,92 @@ +import { + infiniteQueryOptions, + queryOptions, + useQuery, +} from '@tanstack/react-query'; +import { + getProduct, + getProductsPage, + getProductRecommendations, +} from '@/services/shopify/catalog'; + +// Pure fetchers live in services/shopify/catalog so server code can use them +// too; re-exported here so components have one place to import from. +export { + getProducts, + getProductsPage, + getProduct, + getProductRecommendations, +} from '@/services/shopify/catalog'; +export type { + Product, + ProductOption, + ProductOptionValue, + ProductsPage, +} from '@/services/shopify/catalog'; + +type ProductsSortKey = 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE'; + +export const productQueryOptions = (handle: string) => + queryOptions({ + queryKey: ['product', handle], + queryFn: () => getProduct(handle), + }); + +export const productRecommendationsQueryOptions = (productId: string) => + queryOptions({ + queryKey: ['product-recommendations', productId], + queryFn: () => getProductRecommendations(productId), + }); + +// Paging is cursor-based: each page's `endCursor` becomes the next `after`. +export const productsInfiniteQueryOptions = ({ + first = 12, + sortKey = 'CREATED_AT', + reverse = true, +}: { + first?: number; + sortKey?: ProductsSortKey; + reverse?: boolean; +} = {}) => + infiniteQueryOptions({ + queryKey: ['products', { first, sortKey, reverse }], + queryFn: ({ pageParam }) => + getProductsPage({ first, after: pageParam, sortKey, reverse }), + initialPageParam: null as string | null, + getNextPageParam: (page) => (page.hasNextPage ? page.endCursor : undefined), + }); + +const errorMessage = (error: Error | null, fallback: string) => + error ? error.message || fallback : null; + +// Fetch a single product +export function useProduct(handle: string | null) { + const { data, isPending, error, refetch } = useQuery({ + ...productQueryOptions(handle ?? ''), + enabled: Boolean(handle), + }); + + return { + product: data ?? null, + loading: Boolean(handle) && isPending, + error: + errorMessage(error, 'Failed to load product') ?? + (data === null ? 'Product not found' : null), + refetch, + }; +} + +// Fetch product recommendations +export function useProductRecommendations(productId: string | null) { + const { data, isPending, error, refetch } = useQuery({ + ...productRecommendationsQueryOptions(productId ?? ''), + enabled: Boolean(productId), + }); + + return { + recommendations: data ?? [], + loading: Boolean(productId) && isPending, + error: errorMessage(error, 'Failed to load recommendations'), + refetch, + }; +} diff --git a/src/hooks/use-shopify-search.ts b/src/hooks/use-shopify-search.ts new file mode 100644 index 0000000..fcbc5c4 --- /dev/null +++ b/src/hooks/use-shopify-search.ts @@ -0,0 +1,51 @@ +import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; +import { + searchProducts, + searchSuggestions, + type SearchSortKey, +} from '@/services/shopify/catalog'; + +// Re-exported from the catalogue module so both components and the Hono server +// can search the storefront. +export { searchProducts, searchSuggestions } from '@/services/shopify/catalog'; +export type { + SearchSortKey, + SearchFilter, + SearchFilterValue, + SearchProductsResult, + SearchSuggestion, +} from '@/services/shopify/catalog'; + +export const searchInfiniteQueryOptions = ({ + query, + first = 24, + sortKey = 'RELEVANCE', + reverse = false, + filterInputs = [], +}: { + query: string; + first?: number; + sortKey?: SearchSortKey; + reverse?: boolean; + filterInputs?: string[]; +}) => + infiniteQueryOptions({ + queryKey: ['search', { query, first, sortKey, reverse, filterInputs }], + queryFn: ({ pageParam }) => + searchProducts({ + query, + first, + after: pageParam, + sortKey, + reverse, + filterInputs, + }), + initialPageParam: null as string | null, + getNextPageParam: (page) => (page.hasNextPage ? page.endCursor : undefined), + }); + +export const searchSuggestionsQueryOptions = (query: string, first = 3) => + queryOptions({ + queryKey: ['search-suggestions', query, first], + queryFn: () => searchSuggestions(query, first), + }); diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..2ced745 --- /dev/null +++ b/src/lib/seo.ts @@ -0,0 +1,49 @@ +import { site } from '@/config/site'; + +// Builds the head tags each route used to declare through Next's `metadata`. +// Routes pass their own title; the store name is appended unless `absolute`. + +interface SeoOptions { + title: string; + /** Use the title as-is rather than appending the store name. */ + absolute?: boolean; + description?: string; + /** Path for the canonical link and og:url, e.g. `/products/shirt`. */ + path?: string; + image?: { url: string; alt?: string } | null; +} + +export function seo({ + title, + absolute = false, + description = site.description, + path, + image, +}: SeoOptions) { + const fullTitle = absolute ? title : `${title} — ${site.storeName}`; + const url = path ? new URL(path, site.url).toString() : undefined; + + const meta = [ + { title: fullTitle }, + { name: 'description', content: description }, + { property: 'og:type', content: 'website' }, + { property: 'og:site_name', content: site.storeName }, + { property: 'og:title', content: title }, + { property: 'og:description', content: description }, + ...(url ? [{ property: 'og:url', content: url }] : []), + ...(image + ? [ + { property: 'og:image', content: image.url }, + { property: 'og:image:alt', content: image.alt ?? title }, + { name: 'twitter:image', content: image.url }, + ] + : []), + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + ]; + + const links = url ? [{ rel: 'canonical', href: url }] : []; + + return { meta, links }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..0225e59 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,12 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function truncate(str: string, maxLength: number): string { + if (!str) return ''; + if (str.length <= maxLength) return str; + return str.slice(0, maxLength).trim() + '...'; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..175e327 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,42 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider, createRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; +import { ErrorMessage } from '@/components/route-states'; +import './styles.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Catalogue data is fine to reuse for a minute across navigations. + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + }, + }, +}); + +const router = createRouter({ + routeTree, + context: { queryClient }, + // Hovering a link starts its route loader, so most pages open instantly. + defaultPreload: 'intent', + // TanStack Query owns caching; always let loaders ask it. + defaultPreloadStaleTime: 0, + scrollRestoration: true, + defaultErrorComponent: ({ error }) => , +}); + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx new file mode 100644 index 0000000..742a314 --- /dev/null +++ b/src/routes/__root.tsx @@ -0,0 +1,56 @@ +import { + HeadContent, + Outlet, + createRootRouteWithContext, + useRouterState, +} from '@tanstack/react-router'; +import type { QueryClient } from '@tanstack/react-query'; +import Header from '@/components/shopify/header'; +import Footer from '@/components/shopify/footer'; +import { site } from '@/config/site'; +import { seo } from '@/lib/seo'; +import { NotFound } from '@/components/route-states'; + +export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()( + { + head: () => seo({ title: site.storeName, absolute: true }), + component: RootLayout, + notFoundComponent: NotFound, + } +); + +// Thin bar along the top while a route loader is still running. +function NavigationProgress() { + const loading = useRouterState({ select: (s) => s.status === 'pending' }); + + return ( +
+ ); +} + +function RootLayout() { + return ( + <> + + +
+ {/* Header owns the cart drawer, search and account menu, so every + route gets them by rendering here rather than page by page. */} +
+
+ +
+
+
+ + ); +} diff --git a/src/routes/account/activate/$id/$token.tsx b/src/routes/account/activate/$id/$token.tsx new file mode 100644 index 0000000..164d49e --- /dev/null +++ b/src/routes/account/activate/$id/$token.tsx @@ -0,0 +1,34 @@ +import { createFileRoute } from '@tanstack/react-router'; +import AccountForm from '@/components/shopify/account-form'; +import { seo } from '@/lib/seo'; + +// Shopify's emailed activation link is /account/activate/{id}/{token}. +export const Route = createFileRoute('/account/activate/$id/$token')({ + head: () => seo({ title: 'Activate your account' }), + component: ActivatePage, +}); + +function ActivatePage() { + const { id, token } = Route.useParams(); + + return ( +
+ +
+ ); +} diff --git a/src/routes/account/index.tsx b/src/routes/account/index.tsx new file mode 100644 index 0000000..72017c3 --- /dev/null +++ b/src/routes/account/index.tsx @@ -0,0 +1,38 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; +import OrderHistory from '@/components/shopify/order-history'; +import { customerQueryOptions } from '@/hooks/use-account'; +import { seo } from '@/lib/seo'; + +export const Route = createFileRoute('/account/')({ + loader: async ({ context: { queryClient } }) => { + const customer = await queryClient + .fetchQuery(customerQueryOptions()) + .catch(() => null); + + // No session, or an expired or revoked token: both read as signed out. + if (!customer) throw redirect({ to: '/account/login' }); + + return { customer }; + }, + head: () => seo({ title: 'Order history' }), + component: AccountPage, +}); + +function AccountPage() { + const { customer } = Route.useLoaderData(); + + return ( +
+

+ Order history +

+

+ {customer.displayName} · {customer.email} +

+ +
+ +
+
+ ); +} diff --git a/src/routes/account/login.tsx b/src/routes/account/login.tsx new file mode 100644 index 0000000..178e1ef --- /dev/null +++ b/src/routes/account/login.tsx @@ -0,0 +1,43 @@ +import { createFileRoute } from '@tanstack/react-router'; +import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; +import { seo } from '@/lib/seo'; + +export const Route = createFileRoute('/account/login')({ + head: () => seo({ title: 'Sign in' }), + component: LoginPage, +}); + +function LoginPage() { + return ( +
+ + + New here?{' '} + + Create an account + + + + Forgot your password? + + + } + /> +
+ ); +} diff --git a/src/routes/account/recover.tsx b/src/routes/account/recover.tsx new file mode 100644 index 0000000..b9193d7 --- /dev/null +++ b/src/routes/account/recover.tsx @@ -0,0 +1,28 @@ +import { createFileRoute } from '@tanstack/react-router'; +import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; +import { seo } from '@/lib/seo'; + +export const Route = createFileRoute('/account/recover')({ + head: () => seo({ title: 'Reset password' }), + component: RecoverPage, +}); + +function RecoverPage() { + return ( +
+ Back to sign in + } + /> +
+ ); +} diff --git a/src/routes/account/register.tsx b/src/routes/account/register.tsx new file mode 100644 index 0000000..316dfbf --- /dev/null +++ b/src/routes/account/register.tsx @@ -0,0 +1,48 @@ +import { createFileRoute } from '@tanstack/react-router'; +import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; +import { seo } from '@/lib/seo'; + +export const Route = createFileRoute('/account/register')({ + head: () => seo({ title: 'Create account' }), + component: RegisterPage, +}); + +function RegisterPage() { + return ( +
+ + Already have an account?{' '} + Sign in + + } + /> +
+ ); +} diff --git a/src/routes/account/reset/$id/$token.tsx b/src/routes/account/reset/$id/$token.tsx new file mode 100644 index 0000000..a251e0e --- /dev/null +++ b/src/routes/account/reset/$id/$token.tsx @@ -0,0 +1,33 @@ +import { createFileRoute } from '@tanstack/react-router'; +import AccountForm from '@/components/shopify/account-form'; +import { seo } from '@/lib/seo'; + +// Shopify's emailed reset link is /account/reset/{id}/{token}. +export const Route = createFileRoute('/account/reset/$id/$token')({ + head: () => seo({ title: 'Set a new password' }), + component: ResetPage, +}); + +function ResetPage() { + const { id, token } = Route.useParams(); + + return ( +
+ +
+ ); +} diff --git a/src/routes/collections/$handle.tsx b/src/routes/collections/$handle.tsx new file mode 100644 index 0000000..d41da88 --- /dev/null +++ b/src/routes/collections/$handle.tsx @@ -0,0 +1,45 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { CollectionPage } from '@/components/shopify/collection-detail'; +import { collectionProductsInfiniteQueryOptions } from '@/hooks/use-shopify-collections'; +import { truncate } from '@/lib/utils'; +import { seo } from '@/lib/seo'; + +// Must match the component's first-page options so the loader's fetch is the +// one the page renders from. +const FIRST_PAGE = { first: 24 } as const; + +export const Route = createFileRoute('/collections/$handle')({ + // Split the page and its pending state into one chunk; they share a body. + codeSplitGroupings: [['component', 'pendingComponent']], + loader: async ({ params: { handle }, context: { queryClient } }) => { + // A failed lookup renders the component's own error state, not a 500. + const data = await queryClient + .ensureInfiniteQueryData( + collectionProductsInfiniteQueryOptions(handle, FIRST_PAGE) + ) + .catch(() => null); + + return { collection: data?.pages[0]?.collection ?? null }; + }, + head: ({ loaderData }) => { + const collection = loaderData?.collection; + if (!collection) return seo({ title: 'Collection' }); + + return seo({ + title: collection.title, + description: collection.description + ? truncate(collection.description, 160) + : `Shop the ${collection.title} collection.`, + path: `/collections/${collection.handle}`, + image: collection.image + ? { + url: collection.image.url, + alt: collection.image.altText ?? collection.title, + } + : null, + }); + }, + // The page has its own skeleton, driven by the same in-flight query. + pendingComponent: CollectionPage, + component: CollectionPage, +}); diff --git a/src/routes/collections/index.tsx b/src/routes/collections/index.tsx new file mode 100644 index 0000000..7af5346 --- /dev/null +++ b/src/routes/collections/index.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; +import Collections from '@/components/shopify/collections'; +import { seo } from '@/lib/seo'; + +export const Route = createFileRoute('/collections/')({ + head: () => + seo({ + title: 'Collections', + description: 'Browse every collection in the store.', + path: '/collections', + }), + component: () => , +}); diff --git a/src/routes/index.tsx b/src/routes/index.tsx new file mode 100644 index 0000000..68de000 --- /dev/null +++ b/src/routes/index.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router'; +import Products from '@/components/shopify/products'; +import { site } from '@/config/site'; +import { seo } from '@/lib/seo'; + +export const Route = createFileRoute('/')({ + // The home page keeps the bare store name rather than "Shop — Shop". + head: () => seo({ title: site.storeName, absolute: true, path: '/' }), + component: () => ( + + ), +}); diff --git a/src/routes/policies/$handle.tsx b/src/routes/policies/$handle.tsx new file mode 100644 index 0000000..a128e29 --- /dev/null +++ b/src/routes/policies/$handle.tsx @@ -0,0 +1,47 @@ +import { createFileRoute, notFound } from '@tanstack/react-router'; +import { shopPoliciesQueryOptions } from '@/hooks/use-shopify-policies'; +import { site } from '@/config/site'; +import { seo } from '@/lib/seo'; + +export const Route = createFileRoute('/policies/$handle')({ + loader: async ({ params: { handle }, context: { queryClient } }) => { + // There is no lookup-by-handle field, so fetch them all and match. + const policies = await queryClient.ensureQueryData( + shopPoliciesQueryOptions() + ); + const policy = policies.find((p) => p.handle === handle); + + if (!policy) throw notFound(); + return { policy }; + }, + head: ({ loaderData }) => { + const policy = loaderData?.policy; + if (!policy) return seo({ title: 'Policy' }); + + return seo({ + title: policy.title, + description: `Read the ${policy.title.toLowerCase()} for ${site.storeName}.`, + path: `/policies/${policy.handle}`, + }); + }, + component: PolicyPage, +}); + +function PolicyPage() { + const { policy } = Route.useLoaderData(); + + return ( +
+
+

+ {policy.title} +

+ +
+
+
+ ); +} diff --git a/src/routes/products/$handle.tsx b/src/routes/products/$handle.tsx new file mode 100644 index 0000000..b84fc21 --- /dev/null +++ b/src/routes/products/$handle.tsx @@ -0,0 +1,45 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { ProductPage } from '@/components/shopify/product-detail'; +import { + productQueryOptions, + productRecommendationsQueryOptions, +} from '@/hooks/use-shopify-products'; +import { truncate } from '@/lib/utils'; +import { site } from '@/config/site'; +import { seo } from '@/lib/seo'; + +export const Route = createFileRoute('/products/$handle')({ + // Split the page and its pending state into one chunk; they share a body. + codeSplitGroupings: [['component', 'pendingComponent']], + loader: async ({ params: { handle }, context: { queryClient } }) => { + // ProductDetail renders its own empty state, so a failed lookup here falls + // back to generic tags rather than an error page. + const product = await queryClient + .ensureQueryData(productQueryOptions(handle)) + .catch(() => null); + + // Warm the recommendations without holding up the page. + if (product) { + queryClient.prefetchQuery(productRecommendationsQueryOptions(product.id)); + } + + return { product }; + }, + head: ({ loaderData }) => { + const product = loaderData?.product; + if (!product) return seo({ title: 'Product' }); + + const image = product.images.edges[0]?.node; + + return seo({ + title: product.title, + description: product.description + ? truncate(product.description, 160) + : site.description, + path: `/products/${product.handle}`, + image: image ? { url: image.url, alt: image.altText ?? product.title } : null, + }); + }, + pendingComponent: ProductPage, + component: ProductPage, +}); diff --git a/src/routes/search.tsx b/src/routes/search.tsx new file mode 100644 index 0000000..d9755d0 --- /dev/null +++ b/src/routes/search.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router'; +import SearchResults from '@/components/shopify/search-results'; +import { seo } from '@/lib/seo'; + +interface SearchParams { + q?: string; +} + +export const Route = createFileRoute('/search')({ + validateSearch: (search: Record): SearchParams => ({ + q: typeof search.q === 'string' && search.q ? search.q : undefined, + }), + head: () => + seo({ + title: 'Search', + description: 'Search the catalogue by product name, description or type.', + path: '/search', + }), + component: SearchPage, +}); + +function SearchPage() { + const { q } = Route.useSearch(); + return ; +} diff --git a/src/routes/shop/collections.tsx b/src/routes/shop/collections.tsx new file mode 100644 index 0000000..e832785 --- /dev/null +++ b/src/routes/shop/collections.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router'; +import Collections from '@/components/shopify/collections'; +import { seo } from '@/lib/seo'; + +export const Route = createFileRoute('/shop/collections')({ + head: () => + seo({ + title: 'Collections', + description: 'Browse every collection in the store.', + // Same listing as /collections; point crawlers at the canonical route. + path: '/collections', + }), + component: () => , +}); diff --git a/src/services/shopify/catalog.ts b/src/services/shopify/catalog.ts new file mode 100644 index 0000000..f777830 --- /dev/null +++ b/src/services/shopify/catalog.ts @@ -0,0 +1,446 @@ +// Server-safe Shopify catalogue access. +// +// These are plain async functions with no React imports, so they can be called +// from the Hono server as well as from the TanStack Query options in +// hooks/use-shopify-*.ts, which re-export them. +import type { StorefrontApi } from '@shopify/hydrogen'; +import { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; +import { + GET_PRODUCTS_QUERY, + GET_PRODUCT_QUERY, + QUERY_PRODUCT_RECOMMENDATIONS, +} from '@/graphql/products'; +import { + GET_COLLECTIONS_QUERY, + GET_COLLECTION_PRODUCTS_QUERY, +} from '@/graphql/collections'; +import { + SEARCH_PRODUCTS_QUERY, + SEARCH_SUGGESTIONS_QUERY, +} from '@/graphql/search'; + +// Optional fields are `| null` rather than just optional: the Storefront API +// returns explicit nulls, and the typed `gql()` documents now surface that. +interface ProductImage { + url: string; + altText?: string | null; +} + +interface ProductPrice { + amount: string; + currencyCode: string; +} + +interface ProductVariant { + id: string; + title: string; + price: ProductPrice; + availableForSale: boolean; + selectedOptions: Array<{ + name: string; + value: string; + }>; + image?: ProductImage | null; +} + +export interface ProductOptionValue { + id: string; + name: string; + swatch?: { + color?: string | null; + image?: { + previewImage?: { + url: string; + } | null; + } | null; + } | null; + firstSelectableVariant?: { + id: string; + image?: ProductImage | null; + } | null; +} + +export interface ProductOption { + id: string; + name: string; + values: string[]; + optionValues?: ProductOptionValue[]; +} + +/** + * Single-variant products still carry one synthetic option — `Title` with the + * lone value `Default Title`. It isn't a real choice, so keep it out of the UI. + */ +export const isDefaultTitleOption = (option: { + name: string; + values: string[]; +}): boolean => + option.name === 'Title' && + option.values.length === 1 && + option.values[0] === 'Default Title'; + +/** Same synthetic option, as it appears on a variant's `selectedOptions`. */ +export const isDefaultTitleSelection = (selection: { + name: string; + value: string; +}): boolean => + selection.name === 'Title' && selection.value === 'Default Title'; + +export interface Product { + id: string; + title: string; + description?: string; + descriptionHtml?: string; + handle: string; + images: { + edges: Array<{ + node: ProductImage; + }>; + }; + priceRange: { + minVariantPrice: ProductPrice; + }; + compareAtPriceRange?: { + minVariantPrice: ProductPrice; + }; + variants: { + edges: Array<{ + node: ProductVariant; + }>; + }; + options: ProductOption[]; +} + +interface UseProductsOptions { + first?: number; + /** Cursor from a previous page's `endCursor`; omit for the first page. */ + after?: string | null; + query?: string; + sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE'; + reverse?: boolean; +} + +export interface ProductsPage { + products: Product[]; + hasNextPage: boolean; + endCursor: string | null; +} + +interface UseProductsReturn { + products: Product[]; + loading: boolean; + error: string | null; + refetch: () => Promise; +} + +// Fetch multiple products +export async function getProducts( + options: UseProductsOptions = {} +): Promise { + const { products } = await getProductsPage(options); + return products; +} + +// Same fetch, but keeps the cursor so callers can page through the catalogue. +export async function getProductsPage({ + first = 20, + after = null, + query = '', + sortKey = 'BEST_SELLING', + reverse = false, +}: UseProductsOptions = {}): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(GET_PRODUCTS_QUERY, { + variables: { first, after, query, sortKey, reverse }, + }), + 'GetProducts' + ); + + const { edges, pageInfo } = data.products; + + return { + products: edges.map((edge: { node: Product }) => edge.node), + hasNextPage: Boolean(pageInfo?.hasNextPage), + endCursor: pageInfo?.endCursor ?? null, + }; +} + +// Fetch a single product by handle +export async function getProduct(handle: string): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }), + 'GetProduct' + ); + + return data.product; +} + +// Fetch product recommendations +export async function getProductRecommendations(productId: string): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, { + variables: { productId }, + }), + 'GetProductRecommendations' + ); + + return data.productRecommendations ?? []; +} + + +interface CollectionImage { + url: string; + altText?: string | null; +} + +export interface Collection { + id: string; + title: string; + handle: string; + description?: string; + descriptionHtml?: string; + image?: CollectionImage | null; +} + +export interface CollectionWithProducts extends Collection { + products: Product[]; +} + +export type CollectionSortKey = + | 'COLLECTION_DEFAULT' + | 'BEST_SELLING' + | 'CREATED' + | 'PRICE' + | 'TITLE'; + +interface UseCollectionProductsOptions { + first?: number; + after?: string | null; + sortKey?: CollectionSortKey; + reverse?: boolean; + /** Raw `input` strings from the connection's `filters` facets. */ + filterInputs?: string[]; +} + +export interface CollectionProductsPage { + collection: Collection | null; + products: Product[]; + filters: ProductFilterFacet[]; + hasNextPage: boolean; + endCursor: string | null; +} + +export interface ProductFilterFacet { + id: string; + label: string; + type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN'; + values: Array<{ + id: string; + label: string; + count: number; + input: string; + }>; +} + +// Fetch all collections +export async function getCollections(first = 50): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }), + 'GetCollections' + ); + + return data.collections.edges.map((edge) => edge.node); +} + +// Fetch products in a collection by handle +export async function getCollectionProducts( + handle: string, + options: UseCollectionProductsOptions = {} +): Promise { + const page = await getCollectionProductsPage(handle, options); + if (!page.collection) return null; + + return { ...page.collection, products: page.products }; +} + +// Same fetch, but keeps the cursor and facet list for filtering and paging. +export async function getCollectionProductsPage( + handle: string, + { + first = 50, + after = null, + sortKey = 'COLLECTION_DEFAULT', + reverse = false, + filterInputs = [], + }: UseCollectionProductsOptions = {} +): Promise { + const filters = parseFilterInputs(filterInputs); + + const data = unwrapStorefrontResult( + await storefront.graphql(GET_COLLECTION_PRODUCTS_QUERY, { + variables: { + handle, + first, + after, + sortKey, + reverse, + filters: filters.length ? filters : null, + }, + }), + 'GetCollectionProducts' + ); + + const collection = data.collection; + if (!collection) { + return { + collection: null, + products: [], + filters: [], + hasNextPage: false, + endCursor: null, + }; + } + + const { edges, pageInfo, filters: facets } = collection.products; + + return { + collection, + products: edges.map((edge: { node: Product }) => edge.node), + filters: facets ?? [], + hasNextPage: Boolean(pageInfo?.hasNextPage), + endCursor: pageInfo?.endCursor ?? null, + }; +} + + +export type SearchSortKey = 'RELEVANCE' | 'PRICE'; + +export interface SearchFilterValue { + id: string; + label: string; + count: number; + /** JSON string accepted back as a `ProductFilter` input. */ + input: string; +} + +export interface SearchFilter { + id: string; + label: string; + type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN'; + values: SearchFilterValue[]; +} + +export interface SearchProductsResult { + products: Product[]; + totalCount: number; + filters: SearchFilter[]; + hasNextPage: boolean; + endCursor: string | null; +} + +export interface SearchSuggestion { + id: string; + title: string; + handle: string; + featuredImage?: { + url: string; + altText?: string | null; + } | null; + priceRange: { + minVariantPrice: { + amount: string; + currencyCode: string; + }; + }; +} + +interface SearchProductsOptions { + query: string; + first?: number; + after?: string | null; + sortKey?: SearchSortKey; + reverse?: boolean; + /** Raw `input` strings from the facets, parsed back into filter objects. */ + filterInputs?: string[]; +} + +// The `ProductFilter` input shape, taken from the query that consumes it so it +// tracks the schema rather than being restated here. +type ProductFilterInput = NonNullable< + StorefrontApi.VariablesOf['filters'] +>[number]; + +// Facet `input` values are opaque JSON strings produced by Shopify and handed +// straight back as filter inputs, so they are parsed, not constructed. +function parseFilterInputs(inputs: string[]): ProductFilterInput[] { + return inputs.flatMap((input) => { + try { + return [JSON.parse(input) as ProductFilterInput]; + } catch { + console.warn('Ignoring malformed product filter input:', input); + return []; + } + }); +} + +export async function searchProducts({ + query, + first = 24, + after = null, + sortKey = 'RELEVANCE', + reverse = false, + filterInputs = [], +}: SearchProductsOptions): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(SEARCH_PRODUCTS_QUERY, { + variables: { + query, + first, + after, + sortKey, + reverse, + productFilters: filterInputs.length + ? parseFilterInputs(filterInputs) + : null, + }, + }), + 'SearchProducts' + ); + + const search = data.search; + + return { + products: search.edges + .map((edge) => edge.node) + .filter((node): node is Extract => + node.__typename === 'Product' + ), + totalCount: search.totalCount ?? 0, + filters: search.productFilters ?? [], + hasNextPage: Boolean(search.pageInfo?.hasNextPage), + endCursor: search.pageInfo?.endCursor ?? null, + }; +} + +export async function searchSuggestions( + query: string, + first = 3 +): Promise<{ products: SearchSuggestion[]; totalCount: number }> { + const data = unwrapStorefrontResult( + await storefront.graphql(SEARCH_SUGGESTIONS_QUERY, { + variables: { query, first }, + }), + 'SearchSuggestions' + ); + + const search = data.search; + + return { + products: search.edges + .map((edge) => edge.node) + .filter((node): node is Extract => + node.__typename === 'Product' + ), + totalCount: search.totalCount ?? 0, + }; +} diff --git a/src/services/shopify/client.ts b/src/services/shopify/client.ts new file mode 100644 index 0000000..45f9278 --- /dev/null +++ b/src/services/shopify/client.ts @@ -0,0 +1,92 @@ +// Storefront API clients, built on `@shopify/hydrogen`. +// +// Call sites use the package's own API — `storefront.graphql(DOCUMENT, { +// variables })` — and pass the result through `unwrapStorefrontResult`, which +// applies this app's error policy: fail loudly. Hydrogen deliberately does not +// do that itself, because a 200 carrying partial data and GraphQL errors is a +// valid response that some callers want to render. +import { + createShopifyRequestContext, + createStorefrontClient, + type GraphQLFormattedError, +} from '@shopify/hydrogen'; +import { + SHOPIFY_API_VERSION, + SHOPIFY_PUBLIC_ACCESS_TOKEN, + SHOPIFY_STORE_DOMAIN, +} from '@/services/shopify/config'; + +// No incoming request and no buyer context: this client is module-scoped and +// serves both the Hono API and the browser-side hooks, so it must not close +// over per-request state. `$country`/`$language` are injected from this i18n. +const requestContext = createShopifyRequestContext({ + request: { headers: new Headers() }, + i18n: { country: 'US', language: 'EN' }, +}); + +/** + * Workaround for a bug in this preview build of `@shopify/hydrogen`. + * + * The client tags every request with `X-Hydrogen-Version`, but the Storefront + * API does not list that header in its CORS `access-control-allow-headers`. + * Browsers therefore reject the preflight and `fetch` throws, which hydrogen + * reports as the generic "SFAPI request failed". It only bites against real + * stores — `mock.shop` answers `access-control-allow-headers: *`. + * + * Stripped in the browser only: server-side requests are not subject to CORS, + * so they keep sending the header. Remove this once the API allows it (or once + * these queries move server-side, which is the better long-term fix). + */ +const CORS_BLOCKED_HEADERS = ['X-Hydrogen-Version']; + +const storefrontFetch = ((url, init) => { + const headers = new Headers(init?.headers); + + if (typeof document !== 'undefined') { + for (const header of CORS_BLOCKED_HEADERS) headers.delete(header); + } + + return globalThis.fetch(url, { ...init, cache: 'no-store', headers }); +}) as typeof globalThis.fetch; + +/** + * The one Storefront client. Uncached at the HTTP layer, because carts and + * customer reads must never serve a stale response — response caching for + * catalogue data happens in TanStack Query instead (see `staleTime`s). + */ +export const storefront = createStorefrontClient({ + type: 'public', + requestContext, + config: { + storeDomain: SHOPIFY_STORE_DOMAIN!, + apiVersion: SHOPIFY_API_VERSION, + publicStorefrontToken: SHOPIFY_PUBLIC_ACCESS_TOKEN, + fetch: storefrontFetch, + }, +}); + +/** + * Returns the data from a `graphql()` result, throwing if Shopify reported any + * GraphQL errors. Transport failures — non-200, timeouts, unparseable bodies — + * have already thrown as `StorefrontApiError` by this point. + * + * `operation` names the query in the log and the thrown message, so a failure + * points at the call site rather than just at "Shopify". + */ +export function unwrapStorefrontResult( + result: { data: TData | null; errors?: GraphQLFormattedError[] }, + operation: string +): TData { + if (result.errors?.length) { + console.error(`Shopify GraphQL errors (${operation}):`, result.errors); + throw new Error( + `Shopify GraphQL errors (${operation}): ${JSON.stringify(result.errors)}` + ); + } + + if (result.data == null) { + throw new Error(`Shopify returned no data for ${operation}.`); + } + + return result.data; +} diff --git a/src/services/shopify/config.ts b/src/services/shopify/config.ts new file mode 100644 index 0000000..18cb68d --- /dev/null +++ b/src/services/shopify/config.ts @@ -0,0 +1,16 @@ +// Storefront configuration, read from the environment in one place. +// +// These are all `VITE_*`, so Vite inlines them at build time and they are safe +// to read from the browser bundle as well as the Hono server. +export const SHOPIFY_STORE_DOMAIN = import.meta.env.VITE_SHOPIFY_DOMAIN; + +/** + * Public Storefront API access token. Safe to expose to the browser — that is + * what "public" means here. Omitted for tokenless storefronts such as + * `mock.shop`. Never put a *private* token behind a `VITE_` name. + */ +export const SHOPIFY_PUBLIC_ACCESS_TOKEN = import.meta.env + .VITE_SHOPIFY_PUBLIC_ACCESS_TOKEN; + +export const SHOPIFY_API_VERSION = + import.meta.env.VITE_SHOPIFY_API_VERSION || '2026-07'; diff --git a/src/services/shopify/customer.ts b/src/services/shopify/customer.ts new file mode 100644 index 0000000..53589f1 --- /dev/null +++ b/src/services/shopify/customer.ts @@ -0,0 +1,248 @@ +// Server-safe customer account access. +// +// The customer access token is a credential: it is only ever handled here and +// in the /api/account route handlers, and is stored in an httpOnly cookie so +// client JavaScript can never read it. +import { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; +import { + CUSTOMER_QUERY, + CUSTOMER_CREATE_MUTATION, + CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, + CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION, + CUSTOMER_RECOVER_MUTATION, + CUSTOMER_RESET_MUTATION, + CUSTOMER_ACTIVATE_MUTATION, + CUSTOMER_UPDATE_MUTATION, +} from '@/graphql/customer'; + +export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken'; + +export interface CustomerUserError { + /** One of `CustomerErrorCode`; kept as a string so new codes don't break. */ + code?: string | null; + field?: string[] | null; + message: string; +} + +export interface CustomerAddress { + id: string; + firstName?: string | null; + lastName?: string | null; + address1?: string | null; + address2?: string | null; + city?: string | null; + province?: string | null; + zip?: string | null; + country?: string | null; + phone?: string | null; +} + +export interface CustomerOrder { + id: string; + orderNumber: number; + processedAt: string; + financialStatus?: string | null; + fulfillmentStatus?: string | null; + statusUrl?: string | null; + currentTotalPrice: { amount: string; currencyCode: string }; + lineItems: { + edges: Array<{ + node: { + title: string; + quantity: number; + variant?: { image?: { url: string; altText?: string | null } | null } | null; + }; + }>; + }; +} + +export interface Customer { + id: string; + email: string | null; + firstName?: string | null; + lastName?: string | null; + phone?: string | null; + displayName: string; + acceptsMarketing?: boolean; + createdAt?: string; + defaultAddress?: CustomerAddress | null; + orders?: { edges: Array<{ node: CustomerOrder }> }; +} + +export interface AccessToken { + accessToken: string; + expiresAt: string; +} + +/** Either a token (success) or the errors Shopify reported. */ +export interface AuthResult { + token: AccessToken | null; + errors: CustomerUserError[]; +} + +const firstMessage = (errors: CustomerUserError[]) => + errors[0]?.message ?? 'Something went wrong. Please try again.'; + +export { firstMessage as customerErrorMessage }; + +// Shopify returns a null mutation payload when the mutation could not run at +// all. That is not a success, so it surfaces as a generic error rather than an +// empty error list, which callers read as "it worked". +const MUTATION_FAILED: CustomerUserError[] = [ + { message: 'Something went wrong. Please try again.' }, +]; + +export async function createCustomer(input: { + email: string; + password: string; + firstName?: string; + lastName?: string; + acceptsMarketing?: boolean; +}): Promise<{ errors: CustomerUserError[] }> { + const data = unwrapStorefrontResult( + await storefront.graphql(CUSTOMER_CREATE_MUTATION, { + variables: { input }, + }), + 'CustomerCreate' + ); + + const result = data.customerCreate; + if (!result) return { errors: MUTATION_FAILED }; + + return { errors: result.customerUserErrors ?? [] }; +} + +export async function login( + email: string, + password: string +): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, { + variables: { input: { email, password } }, + }), + 'CustomerAccessTokenCreate' + ); + + const result = data.customerAccessTokenCreate; + if (!result) return { token: null, errors: MUTATION_FAILED }; + + return { + token: result.customerAccessToken ?? null, + errors: result.customerUserErrors ?? [], + }; +} + +export async function logout(accessToken: string): Promise { + try { + await storefront.graphql(CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION, { + variables: { customerAccessToken: accessToken }, + }); + } catch (err) { + // The cookie is cleared regardless; a failed revoke shouldn't block logout. + console.error('Failed to revoke customer access token:', err); + } +} + +// Always resolves without error detail: revealing whether an address exists +// would leak account membership. +export async function recoverPassword(email: string): Promise { + try { + await storefront.graphql(CUSTOMER_RECOVER_MUTATION, { + variables: { email }, + }); + } catch (err) { + console.error('Password recovery request failed:', err); + } +} + +export async function resetPassword( + id: string, + resetToken: string, + password: string +): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(CUSTOMER_RESET_MUTATION, { + variables: { id, input: { resetToken, password } }, + }), + 'CustomerReset' + ); + + const result = data.customerReset; + if (!result) return { token: null, errors: MUTATION_FAILED }; + + return { + token: result.customerAccessToken ?? null, + errors: result.customerUserErrors ?? [], + }; +} + +export async function activateAccount( + id: string, + activationToken: string, + password: string +): Promise { + const data = unwrapStorefrontResult( + await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, { + variables: { id, input: { activationToken, password } }, + }), + 'CustomerActivate' + ); + + const result = data.customerActivate; + if (!result) return { token: null, errors: MUTATION_FAILED }; + + return { + token: result.customerAccessToken ?? null, + errors: result.customerUserErrors ?? [], + }; +} + +export async function getCustomer( + accessToken: string, + orderCount = 10 +): Promise { + try { + const data = unwrapStorefrontResult( + await storefront.graphql(CUSTOMER_QUERY, { + variables: { customerAccessToken: accessToken, orderCount }, + }), + 'GetCustomer' + ); + + return data.customer ?? null; + } catch (err) { + // An expired or revoked token reads as "not signed in". + console.error('Failed to load customer:', err); + return null; + } +} + +export async function updateCustomer( + accessToken: string, + customer: { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + } +): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> { + const data = unwrapStorefrontResult( + await storefront.graphql(CUSTOMER_UPDATE_MUTATION, { + variables: { customerAccessToken: accessToken, customer }, + }), + 'CustomerUpdate' + ); + + const result = data.customerUpdate; + if (!result) return { customer: null, errors: MUTATION_FAILED }; + + return { + customer: result.customer ?? null, + errors: result.customerUserErrors ?? [], + }; +} + +// Shopify's emailed links carry a numeric id; the mutations want a GID. +export function toCustomerGid(id: string): string { + return id.startsWith('gid://') ? id : `gid://shopify/Customer/${id}`; +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..26534ec --- /dev/null +++ b/src/styles.css @@ -0,0 +1,198 @@ +@import 'tailwindcss'; +@import '@fontsource-variable/geist'; +@import '@fontsource-variable/geist-mono'; + +@custom-variant dark (&:is(.dark *)); + +@theme { + /* Refined neutral modern palette */ + --color-background: hsl(0 0% 100%); + --color-foreground: hsl(240 10% 3.9%); + + /* Card */ + --color-card: hsl(0 0% 100%); + --color-card-foreground: hsl(240 10% 3.9%); + + /* Popover */ + --color-popover: hsl(0 0% 100%); + --color-popover-foreground: hsl(240 10% 3.9%); + + /* Primary - Deep neutral slate for professional look */ + --color-primary: hsl(240 5.9% 10%); + --color-primary-foreground: hsl(0 0% 98%); + + /* Secondary */ + --color-secondary: hsl(240 4.8% 95.9%); + --color-secondary-foreground: hsl(240 5.9% 10%); + + /* Muted */ + --color-muted: hsl(240 4.8% 95.9%); + --color-muted-foreground: hsl(240 3.8% 46.1%); + + /* Accent - Subtle warm gray */ + --color-accent: hsl(30 6.7% 95%); + --color-accent-foreground: hsl(240 5.9% 10%); + + /* Destructive */ + --color-destructive: hsl(0 84.2% 60.2%); + --color-destructive-foreground: hsl(0 0% 98%); + + /* Shop (accelerated checkout) purple */ + --color-shop: #5a31f4; + + /* Border */ + --color-border: hsl(240 5.9% 90%); + --color-input: hsl(240 5.9% 90%); + --color-ring: hsl(240 5.9% 10%); + + /* Radius - Modern larger corners */ + --radius-sm: 0.5rem; + --radius-md: 0.75rem; + --radius-lg: 1rem; + --radius-xl: 1.25rem; + + /* Typography — Geist Sans / Geist Mono */ + --font-sans: 'Geist Variable', system-ui, sans-serif; + --font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, monospace; + --font-heading: 'Geist Variable', system-ui, sans-serif; + --font-body: 'Geist Variable', system-ui, sans-serif; +} + +/* Base styles for light, modern Shopify storefront */ +/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn + components (e.g. Button's outline variant) rely on this base layer to pick up + the theme's border colour instead of the text colour. */ +@layer base { + * { + border-color: var(--color-border); + } +} + +body { + font-feature-settings: + 'kern' 1, + 'tnum' 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Modern card design for products and collections */ +.card-modern { + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + border: 1px solid var(--color-border); +} + +.card-modern:hover { + transform: translateY(-8px); + box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.1); + border-color: hsl(240 5.9% 85%); +} + +/* Heading styles — regular weight, slightly tightened like the Geist reference */ +h1, +h2, +.font-heading { + font-weight: 400; + letter-spacing: -0.02em; +} + +/* Swipeable rows (mobile galleries) without a visible scrollbar */ +.no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Diagonal strike marking an unavailable product option (size pill, swatch) */ +.option-unavailable { + background-image: linear-gradient( + to top right, + transparent calc(50% - 0.5px), + currentColor calc(50% - 0.5px), + currentColor calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} + +/* Product description (HTML returned by the Storefront API) */ +.product-description p { + margin-bottom: 1rem; +} + +.product-description > :last-child { + margin-bottom: 0; +} + +/* Shop policy body (HTML returned by the Storefront API) */ +.policy-body p, +.policy-body ul, +.policy-body ol { + margin-bottom: 1.5rem; +} + +.policy-body ul, +.policy-body ol { + padding-left: 1.25rem; + list-style: revert; +} + +.policy-body li { + margin-bottom: 0.5rem; +} + +.policy-body h2, +.policy-body h3, +.policy-body h4 { + margin-top: 2.5rem; + margin-bottom: 1rem; + font-size: 1.25rem; + font-weight: 500; +} + +.policy-body a { + text-decoration: underline; + text-underline-offset: 2px; +} + +.policy-body strong { + font-weight: 500; +} + +.policy-body > :last-child { + margin-bottom: 0; +} + +/* Enhanced button styles */ +button, +.btn-modern { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.btn-modern:hover { + transform: translateY(-1px); + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +/* Hero section styles */ +.hero-bg { + background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%); +} + +/* Product gallery improvements */ +.product-gallery-main { + transition: box-shadow 0.3s ease; +} + +.product-gallery-main:hover { + box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset; +} + +/* Clean price display */ +.price-display { + font-feature-settings: 'tnum'; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..f1ebebb --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +interface ImportMetaEnv { + readonly VITE_SHOPIFY_DOMAIN?: string; + readonly VITE_SHOPIFY_PUBLIC_ACCESS_TOKEN?: string; + readonly VITE_SHOPIFY_API_VERSION?: string; + readonly VITE_SITE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c20cb1a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "types": ["vite/client", "node"], + "plugins": [{ "name": "@shopify/hydrogen/ts-plugin" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "server", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..1a842e0 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { tanstackRouter } from '@tanstack/router-plugin/vite'; +import devServer from '@hono/vite-dev-server'; +import { fileURLToPath, URL } from 'node:url'; + +export default defineConfig({ + plugins: [ + // Must run before the React plugin so the generated route tree is in place. + tanstackRouter({ target: 'react', autoCodeSplitting: true }), + react(), + tailwindcss(), + // Serves the Hono API in dev. Everything outside /api falls through to + // Vite, which serves the SPA. + devServer({ + entry: 'server/app.ts', + exclude: [/^(?!\/api(\/|$)).*/], + injectClientScript: false, + }), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + outDir: 'dist/client', + }, +}); diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..175218e --- /dev/null +++ b/yarn.lock @@ -0,0 +1,3616 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 10 + cacheKey: 10c0 + +"@0no-co/graphql.web@npm:^1.0.5, @0no-co/graphql.web@npm:^1.3.1": + version: 1.3.4 + resolution: "@0no-co/graphql.web@npm:1.3.4" + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + peerDependenciesMeta: + graphql: + optional: true + checksum: 10c0/58c61e16664121bd0974609890bf68135469fa1af23c70c3966cf50f0215efac7062a4ff2ded60faaf7575e420b73c0800467e46fc0440942d48490d2f6c13bd + languageName: node + linkType: hard + +"@0no-co/graphqlsp@npm:^1.12.13": + version: 1.17.5 + resolution: "@0no-co/graphqlsp@npm:1.17.5" + dependencies: + "@gql.tada/internal": "npm:^1.2.1" + graphql: "npm:^15.5.0 || ^16.0.0 || ^17.0.0" + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 + checksum: 10c0/6b40d666ce9b237d0aebb13d17770c009e4416e8955c6636acaef49174b1bde5d612c7a6d7656a3053518cbbfbb85f9d039df78b595233c365878d2091987ed2 + languageName: node + linkType: hard + +"@babel/code-frame@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/code-frame@npm:7.29.7" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.29.7" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/169fc2080169a40c1760155eaaaf739bcb882df0bec76a83adbda5493645bc17270a3434b8848c494b1933e96fe1d147370001e3cda09a39f43ae30f08ef2069 + languageName: node + linkType: hard + +"@babel/compat-data@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/compat-data@npm:7.29.7" + checksum: 10c0/47913f05e08a45a1c9df38c02b4b49e391005085b489432647a1abe112e5d9c75e3be8ea5972b7f6da4ec5d1339922ceb9ea02b8a25d4ed1cb8636e5261f344e + languageName: node + linkType: hard + +"@babel/core@npm:^7.23.7, @babel/core@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/core@npm:7.29.7" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.7" + "@babel/helper-compilation-targets": "npm:^7.29.7" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helpers": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/template": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + "@jridgewell/remapping": "npm:^2.3.5" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10c0/112fb09c24de7a1de64d1de2c31fe65c4e6af4cb2fb6e6d99ea5373e6fc51e75b88581c0efae4c4c68f119a02a988c7106e95011a41530a2fb8ed793c7eaa07b + languageName: node + linkType: hard + +"@babel/generator@npm:^7.29.7, @babel/generator@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/generator@npm:7.29.8" + dependencies: + "@babel/parser": "npm:^7.29.8" + "@babel/types": "npm:^7.29.8" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/7b896696314a659652393b76d78276e236acd0f7fae40a9a1af7f01c76aeafc630dd0966aad6f9386d35d7c674a8e6e2d8e217c44d25fb11460e68afa9ba8441 + languageName: node + linkType: hard + +"@babel/helper-compilation-targets@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-compilation-targets@npm:7.29.7" + dependencies: + "@babel/compat-data": "npm:^7.29.7" + "@babel/helper-validator-option": "npm:^7.29.7" + browserslist: "npm:^4.24.0" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 10c0/4c15fd4c69a0a7047799a28a88460c19cede0a0ee8af994ea169114986f4af48b92c7393a4a3fee0456c11a656eece3448a6ed06354453d6c27cccf17195453b + languageName: node + linkType: hard + +"@babel/helper-globals@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-globals@npm:7.29.7" + checksum: 10c0/f38417c40b1129a1b2b519ca961b9040c8827d1444fd74068702286b91b77089431dc76b6b9d5c1496e5da2a4f3ad329c6946e688ba3fa0d1d0b3d2b4f34f36a + languageName: node + linkType: hard + +"@babel/helper-module-imports@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-module-imports@npm:7.29.7" + dependencies: + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/6adf60d97356027413342a092f818d9678c4f5caff716a33e3284b5ae14e47a9e88059d421dde4ee4894691260039a12602c0e7becadc175602194b40dfa345d + languageName: node + linkType: hard + +"@babel/helper-module-transforms@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-module-transforms@npm:7.29.7" + dependencies: + "@babel/helper-module-imports": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/ee5a2172c24a42be696836f4b0d947489c9729d8adf5821885cf77d1ad5333e3c447368e9a71f67df1099570490553dccf9f888ef0a92a48aa63cb086bd8c7e1 + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b + languageName: node + linkType: hard + +"@babel/helper-validator-option@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-option@npm:7.29.7" + checksum: 10c0/d2a06c6d0ac40ba4a2f219fc2cab249c7a94bacdb2686273b7f9598571c908809b48468ff588915a346e6cc7296f60b581023d1d498b747fed06f779d335c2cc + languageName: node + linkType: hard + +"@babel/helpers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helpers@npm:7.29.7" + dependencies: + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/218e8d10953647c9f44775f5a022b227a182674853b5ea8631889deb7e1a3e4bc870388aaecf59bb8bd92a87f9a96220ed3f70a35bffec6bcf9169ecb67891ac + languageName: node + linkType: hard + +"@babel/parser@npm:^7.23.6, @babel/parser@npm:^7.29.7, @babel/parser@npm:^7.29.8": + version: 7.29.9 + resolution: "@babel/parser@npm:7.29.9" + dependencies: + "@babel/types": "npm:^7.29.8" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/171b16eb4527cdbbb72cb98710f91acb22356e2c40196ba3b7a1b84efa8c55d0b0992f399c8c675c3e17f67fd133676c5c4118e5f20db243f427c8b29b0c3f40 + languageName: node + linkType: hard + +"@babel/template@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/template@npm:7.29.7" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/8bb7f900dcab0e9e1c5ffbc33ca10e0d26b7b2e2ca804becb73ee771b9c4ed6e2908a4ae4a14c08560febb45d2b6b9a173955e42ad404d05f8b04840a14d9c58 + languageName: node + linkType: hard + +"@babel/traverse@npm:^7.23.7, @babel/traverse@npm:^7.29.7": + version: 7.29.8 + resolution: "@babel/traverse@npm:7.29.8" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.8" + "@babel/helper-globals": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.8" + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.8" + debug: "npm:^4.3.1" + checksum: 10c0/87a28989c434add26d787776ac6d30f749b89cb030f2a605c89f671a516a6fa165ac0476f07e2b69feed70128b2734cae1cbbf41dfe95ccf22081bd8f8b91923 + languageName: node + linkType: hard + +"@babel/types@npm:^7.23.6, @babel/types@npm:^7.29.7, @babel/types@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/types@npm:7.29.8" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 + languageName: node + linkType: hard + +"@emnapi/core@npm:^1.11.1": + version: 1.11.3 + resolution: "@emnapi/core@npm:1.11.3" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.3" + tslib: "npm:^2.4.0" + checksum: 10c0/4ca08d349a82d5d2887ccc9e12df630877b0412ddcd59b9faee61e3c3947ccead27a18257a18bfe17abdf2b0709857808ad75d423ac49edd50c32fb140a7ed6e + languageName: node + linkType: hard + +"@emnapi/runtime@npm:^1.11.1": + version: 1.11.3 + resolution: "@emnapi/runtime@npm:1.11.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 + languageName: node + linkType: hard + +"@emnapi/wasi-threads@npm:1.2.3, @emnapi/wasi-threads@npm:^1.2.2": + version: 1.2.3 + resolution: "@emnapi/wasi-threads@npm:1.2.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/5aed84bc5dbe867973b20406ca1ed95661a4892ecaef80378fdf2d37424b3f7b9c428df8a3e1d82b783a3345a530032bf049e699e1f113ea52203c3e9b4e6ce5 + languageName: node + linkType: hard + +"@floating-ui/core@npm:^1.8.0": + version: 1.8.0 + resolution: "@floating-ui/core@npm:1.8.0" + dependencies: + "@floating-ui/utils": "npm:^0.2.12" + checksum: 10c0/cece958f6fab0f8cd7740cd57988c4f8bc5356dca39dd4c093433d44c91952c3a02c7a54d93d7ed6c078b6544d531a29f66621d2839c8bfbb4467dca6aed7df0 + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.8.0": + version: 1.8.0 + resolution: "@floating-ui/dom@npm:1.8.0" + dependencies: + "@floating-ui/core": "npm:^1.8.0" + "@floating-ui/utils": "npm:^0.2.12" + checksum: 10c0/c32b2de7a596b69cfffa909c7028825e14f6319be1b45da529f8943c89fe16b75a49c214e800c87ded3f172353d3b99163d50072f11410488e756961b7b629cc + languageName: node + linkType: hard + +"@floating-ui/react-dom@npm:^2.0.0": + version: 2.1.9 + resolution: "@floating-ui/react-dom@npm:2.1.9" + dependencies: + "@floating-ui/dom": "npm:^1.8.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/9fa9851069738cb9591a1ab2da16e257f193eae5fd2d6d48ad0b1e73f3c049929b6805c5ad43d4af3c1da63c60b215b0ee4651a1fe5caeb80e66d9d423744348 + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.2.12": + version: 0.2.12 + resolution: "@floating-ui/utils@npm:0.2.12" + checksum: 10c0/bb910b90d56991a62011afaf5f8e0c4b7fb6dab69403f4dd17fbd6eb25560b28d533dff9791f270b4f459852e9006a1077b5d73cbedb5e34d9424afc6a828314 + languageName: node + linkType: hard + +"@fontsource-variable/geist-mono@npm:^5.3.0": + version: 5.3.0 + resolution: "@fontsource-variable/geist-mono@npm:5.3.0" + checksum: 10c0/21a4097b3ccce08cbc181199f68a9193adc17d997ef8d883eab7565c6d79a82b2f8d7656f32f97b66f1f0b4ebba78a1a822c6318d55e0c5f572d74683ab036ee + languageName: node + linkType: hard + +"@fontsource-variable/geist@npm:^5.3.0": + version: 5.3.0 + resolution: "@fontsource-variable/geist@npm:5.3.0" + checksum: 10c0/6b1d1d249643377c885d040769bf0964f242e8d03c4470535329ad4eed3d30a8134fea0d45c0b99b8069924a1f506a3bcb5b8d9cea77b7b5234dc7dd57371842 + languageName: node + linkType: hard + +"@gql.tada/cli-utils@npm:1.7.3": + version: 1.7.3 + resolution: "@gql.tada/cli-utils@npm:1.7.3" + dependencies: + "@0no-co/graphqlsp": "npm:^1.12.13" + "@gql.tada/internal": "npm:1.0.9" + graphql: "npm:^15.5.0 || ^16.0.0 || ^17.0.0" + peerDependencies: + "@0no-co/graphqlsp": ^1.12.13 + "@gql.tada/svelte-support": 1.0.2 + "@gql.tada/vue-support": 1.0.2 + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + "@gql.tada/svelte-support": + optional: true + "@gql.tada/vue-support": + optional: true + checksum: 10c0/55405687375cbda9a9e963fc627939a55c64e81816c8d6433add3564f2fc2c0600ec60c98e01de77fa21511713daf9e84b644362d9ce571f0ea5c43851c68b6f + languageName: node + linkType: hard + +"@gql.tada/internal@npm:1.0.9": + version: 1.0.9 + resolution: "@gql.tada/internal@npm:1.0.9" + dependencies: + "@0no-co/graphql.web": "npm:^1.0.5" + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 + checksum: 10c0/5599ab58bc5859bf9d38f13e4ca0aae564cf7e94fbfa9afdb9932d4719fb945cb19da917fe23ed0cf115550ce07d31bcac89c229385fe7077754b54c2bed16e9 + languageName: node + linkType: hard + +"@gql.tada/internal@npm:^1.2.1": + version: 1.2.2 + resolution: "@gql.tada/internal@npm:1.2.2" + dependencies: + "@0no-co/graphql.web": "npm:^1.3.1" + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: 10c0/2861061a64fa88dbd8bac84cff87bc33a30e9ed971cbee9f8d9a52ec27a17ffc64c389fb6a75f2231458524556829b81f4744b55ccb433e46d28e1066a5d4655 + languageName: node + linkType: hard + +"@hono/node-server@npm:^1.19.11": + version: 1.19.17 + resolution: "@hono/node-server@npm:1.19.17" + peerDependencies: + hono: ^4 + checksum: 10c0/e77907e3b5db8ab68ed4f297ad84807bfdd698636408de86ab8d36dab4658504ac4c0ed9d9436bc272a0172b9b7a5a4b578532e0b5baf97674c7b420cb5cff4b + languageName: node + linkType: hard + +"@hono/node-server@npm:^2.1.3": + version: 2.1.3 + resolution: "@hono/node-server@npm:2.1.3" + peerDependencies: + hono: ^4 + checksum: 10c0/1b9996f41be620c301f73529928ccb056574fadf822a0979e4925c17cce209583e7f27376ae2308b4e87ab6c1c7501d6f282333c364ed79f21b99b45b47f7ffb + languageName: node + linkType: hard + +"@hono/vite-dev-server@npm:^0.26.1": + version: 0.26.1 + resolution: "@hono/vite-dev-server@npm:0.26.1" + dependencies: + "@hono/node-server": "npm:^1.19.11" + minimatch: "npm:^9.0.3" + peerDependencies: + hono: "*" + miniflare: "*" + wrangler: "*" + peerDependenciesMeta: + hono: + optional: false + miniflare: + optional: true + wrangler: + optional: true + checksum: 10c0/235334d35c1f6345a7e874191c2a18f2e2018a0713ed8fcef5f0c9aa3097fc0cc15ae6bb95f9536b75fc4b5b1bf4421140e857ffaa87029c4b692c893caa9a2b + languageName: node + linkType: hard + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b + languageName: node + linkType: hard + +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.6.0 + resolution: "@jridgewell/sourcemap-codec@npm:1.6.0" + checksum: 10c0/b5be700e45a775f218589c3466c4ffea630582b4988657652da464e5ab8a7d18bf928ee4fd2363fb346ede4bea9c6ff0bae05a538358c4565ece6199531b5f72 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^1.1.4": + version: 1.2.4 + resolution: "@napi-rs/wasm-runtime@npm:1.2.4" + dependencies: + "@tybys/wasm-util": "npm:^0.10.3" + peerDependencies: + "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.4 + "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.4 + checksum: 10c0/4bd7be975f7c806bf7c33255b53257cdcd603c573e453b2f6878550bcfd59722d0d34c97627ee6a0353d91274b738d29b32ba1f0fe552a5fca1c2da80aa17bdc + languageName: node + linkType: hard + +"@oxc-project/types@npm:=0.152.0": + version: 0.152.0 + resolution: "@oxc-project/types@npm:0.152.0" + checksum: 10c0/5958369f7a2d9fb1f8061ab3aa1abf9b3565cc8895e7e8b3800cd08b6d8077d7ddb4e383ff472811a63302954170a2a52c60fcd7cca38e416910c5f39861d0db + languageName: node + linkType: hard + +"@radix-ui/number@npm:1.1.3": + version: 1.1.3 + resolution: "@radix-ui/number@npm:1.1.3" + checksum: 10c0/da682127b62196a617d30d8b085a0b422b067a6155982aa313d33824cbeae16b95547a41ec74e0cf8cb67b30f5f825b3e9222abf6871b2ae0bf12badad4310d6 + languageName: node + linkType: hard + +"@radix-ui/primitive@npm:1.1.7": + version: 1.1.7 + resolution: "@radix-ui/primitive@npm:1.1.7" + checksum: 10c0/6b1f8b964a57ece3490582d60df79a1c80fdba4c4331b806c4e04b11f42ac46d40a927bfc692dbd881136ec38c3f060c525dbacd6182ba6f3f0bd4284db1028e + languageName: node + linkType: hard + +"@radix-ui/react-accessible-icon@npm:1.1.15": + version: 1.1.15 + resolution: "@radix-ui/react-accessible-icon@npm:1.1.15" + dependencies: + "@radix-ui/react-visually-hidden": "npm:1.2.11" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/90da024abf245ab01ea7b44e5aba9d6a0f46ba97ae42b6ecfcbf3c366cc1286fd47229b24d3f348a3b75b7886c9321e2a9fa790f95017a877f537c78d936a401 + languageName: node + linkType: hard + +"@radix-ui/react-accordion@npm:1.2.20": + version: 1.2.20 + resolution: "@radix-ui/react-accordion@npm:1.2.20" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-collapsible": "npm:1.1.20" + "@radix-ui/react-collection": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/610cbc3155ccc1f3da4b0a85eee009f482ff807e0729d7023b2b3315a6abff75ec6d50b56d53778d069ac61a9bf74670622f9ebe0dd243750ed7e77964ab700c + languageName: node + linkType: hard + +"@radix-ui/react-alert-dialog@npm:1.1.23": + version: 1.1.23 + resolution: "@radix-ui/react-alert-dialog@npm:1.1.23" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-dialog": "npm:1.1.23" + "@radix-ui/react-primitive": "npm:2.1.10" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/3dde4b8162a8c56b3b22283c4d88dfc70dada58792a2a1d5c27b3a978339f97a3ee25fbf2523d2de9f674cb887316897b4765fe5f0fccb0b8ad86818ec9a7ee3 + languageName: node + linkType: hard + +"@radix-ui/react-arrow@npm:1.1.15": + version: 1.1.15 + resolution: "@radix-ui/react-arrow@npm:1.1.15" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.10" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/06fa0a29ae332617d7912e4f25e952d110905264362e7266d2aa423e81aff492d0f1eaa7dd834b3271e6f5d45f56c8c54585d10d771356fb789e50c959f555ca + languageName: node + linkType: hard + +"@radix-ui/react-aspect-ratio@npm:1.1.15": + version: 1.1.15 + resolution: "@radix-ui/react-aspect-ratio@npm:1.1.15" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.10" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/3e8b38a40b8fddfbbf88b9c6acec3a14f3e4c0b17bcc076596160b8ffe5275ceffe5ad109f8db1615a5315ed9353dc15fdf1d7bf8b82c58c5da263ee0c819608 + languageName: node + linkType: hard + +"@radix-ui/react-avatar@npm:1.2.6": + version: 1.2.6 + resolution: "@radix-ui/react-avatar@npm:1.2.6" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + "@radix-ui/react-use-is-hydrated": "npm:0.1.3" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/e5397f3fe042a1440b19076d2dd0131c9f5e42cce6d892151225644bb794af958f508fc49005a85603bd3e55bc9cf78fe7c19685c02a1961646bf415623ab3f6 + languageName: node + linkType: hard + +"@radix-ui/react-checkbox@npm:1.3.11": + version: 1.3.11 + resolution: "@radix-ui/react-checkbox@npm:1.3.11" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-size": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/76420095ba7463637a5fab922552c1afc19c4f4a6144bb2c706c6a3087de9d5ecd21a534284dda58c02861bbc35d613950394bc8a154aed6f899c5fbcc7b71e3 + languageName: node + linkType: hard + +"@radix-ui/react-collapsible@npm:1.1.20": + version: 1.1.20 + resolution: "@radix-ui/react-collapsible@npm:1.1.20" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/ead7072ddbc13d2419af216f2184d81f4211b4bce57e739ed347dcae07897e0cc1e0210cbe66c612c653abd15e46f6fa5832693274aa3f8eff092fd27c22ac08 + languageName: node + linkType: hard + +"@radix-ui/react-collection@npm:1.1.15": + version: 1.1.15 + resolution: "@radix-ui/react-collection@npm:1.1.15" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-slot": "npm:1.3.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/9f9d3f6290026fd383503ebc260d698d59287016e90a3bd5aa4a9696b8cab733b207e5558ddffa3bb877c68f8658c4a3a52fb9314c7cbc83a4b6349808c5bf4c + languageName: node + linkType: hard + +"@radix-ui/react-compose-refs@npm:1.1.5, @radix-ui/react-compose-refs@npm:^1.1.1": + version: 1.1.5 + resolution: "@radix-ui/react-compose-refs@npm:1.1.5" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/ab5cc8cc2f5be285981cffb64aa956d3a28427c649a953d125e900e1befb8b9a55f2d7a31bc01059d29df43ff4ae14ebc6cca7e4c2084d638cef5e5aef7cc263 + languageName: node + linkType: hard + +"@radix-ui/react-context-menu@npm:2.3.7": + version: 2.3.7 + resolution: "@radix-ui/react-context-menu@npm:2.3.7" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-menu": "npm:2.1.24" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/372fde65ea593a8b189b7bdd46c31a2c3411432ad22bd58fd330fe9aa3c8d815cfe4a537a6df9562453b73317d9a059ff6fd4230565beb5326bc3b05846559f3 + languageName: node + linkType: hard + +"@radix-ui/react-context@npm:1.2.2": + version: 1.2.2 + resolution: "@radix-ui/react-context@npm:1.2.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/f5c15339558d64901c99f243a2538be9b28eb20e0dae1e44c8872ed12580ec27f5f309fd491f85f1b0dcb214c90fdae7eede7fe2b0d26569c4dbeffbeff451c0 + languageName: node + linkType: hard + +"@radix-ui/react-dialog@npm:1.1.23, @radix-ui/react-dialog@npm:^1.1.6": + version: 1.1.23 + resolution: "@radix-ui/react-dialog@npm:1.1.23" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-dismissable-layer": "npm:1.1.19" + "@radix-ui/react-focus-guards": "npm:1.1.6" + "@radix-ui/react-focus-scope": "npm:1.1.16" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-portal": "npm:1.1.17" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-slot": "npm:1.3.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.7.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/647e2b3a05826ddab72c68d04b842de47594f8b9c78131ef64a8f94b6e04e311abbe6c2919d645941e2646d8fd5f1d4e9ae9ce65c6492237e180a1d005eddc23 + languageName: node + linkType: hard + +"@radix-ui/react-direction@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-direction@npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/505e6e45b53f2b6a98c82895e1fe0b04c048bf70dbcabe2e3a4bbc05e3cf4759a5e7d9865360eac78da5c56ed3fdd82efce1511179f397525d14de614181de61 + languageName: node + linkType: hard + +"@radix-ui/react-dismissable-layer@npm:1.1.19": + version: 1.1.19 + resolution: "@radix-ui/react-dismissable-layer@npm:1.1.19" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + "@radix-ui/react-use-effect-event": "npm:0.0.5" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/205f2fded4a9e303fcc0d67b78274db84d37d0d35177bb99602c72202a6dcadc39f4cbf936a09e1303b4b42a2b494a279ead9e626cb1a41015152a91ad069fc5 + languageName: node + linkType: hard + +"@radix-ui/react-dropdown-menu@npm:2.1.24": + version: 2.1.24 + resolution: "@radix-ui/react-dropdown-menu@npm:2.1.24" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-menu": "npm:2.1.24" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/bdf10d0f1c3cee34a661a33b0f6effaec26586ed2ebd8fd2214e38c5ba226579c86bd86e68af5a2bd750ab3f09b9e5efb908063c0f4cb0fc228343c0159179d2 + languageName: node + linkType: hard + +"@radix-ui/react-focus-guards@npm:1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-focus-guards@npm:1.1.6" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/5579dfe369f254183a010964e6df8b74c7b36e50189aef18c92538d0196785d68869e3170af77005276f393cd112b7711d5c7cb9e7ec2ca1e0b4794157447978 + languageName: node + linkType: hard + +"@radix-ui/react-focus-scope@npm:1.1.16": + version: 1.1.16 + resolution: "@radix-ui/react-focus-scope@npm:1.1.16" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/2c86dafac81a73295f81834ff85c4af4456671f7d26631cb800837b284318d181c5de043cac2e17bedd6e8a009e3de907ef9435ea9ec5a94518cbaf656368550 + languageName: node + linkType: hard + +"@radix-ui/react-form@npm:0.1.16": + version: 0.1.16 + resolution: "@radix-ui/react-form@npm:0.1.16" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-label": "npm:2.1.15" + "@radix-ui/react-primitive": "npm:2.1.10" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/ea3cc9716311a9c0df3db26e01646a39501adc27ac97e19dda2b7c8cfb4ee98d45d4d2fe1ed4fd811e0d886e99137022691a073ca6c6f42667cfcae34b9beefc + languageName: node + linkType: hard + +"@radix-ui/react-hover-card@npm:1.1.23": + version: 1.1.23 + resolution: "@radix-ui/react-hover-card@npm:1.1.23" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-dismissable-layer": "npm:1.1.19" + "@radix-ui/react-popper": "npm:1.3.7" + "@radix-ui/react-portal": "npm:1.1.17" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/08b62baccf478f9cb5e2fa6da27a85d9b9499735c7a29c4e5986404ec9ee2c393464baec7739fa34a18681f1eb65d784271e991c7eeb6b91573046eaba232621 + languageName: node + linkType: hard + +"@radix-ui/react-id@npm:1.1.4, @radix-ui/react-id@npm:^1.1.0": + version: 1.1.4 + resolution: "@radix-ui/react-id@npm:1.1.4" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/7afa00d12d7f52b5067034108eb7d5551928f9d52875589f858ea1d5117cac3684f8ea291d4f09a88b9499bc4bb6e3279e4a9073d7c8feb15a855d28656b53a4 + languageName: node + linkType: hard + +"@radix-ui/react-label@npm:2.1.15": + version: 2.1.15 + resolution: "@radix-ui/react-label@npm:2.1.15" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.10" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/e1ef6d359488460c2a5e16b0f6513260553dca497db4f5b7d287e5fa38b3623335ff30ede278f5c1f0187fefaa571ecaf45c23f9b26ffefb262e96592f14783b + languageName: node + linkType: hard + +"@radix-ui/react-menu@npm:2.1.24": + version: 2.1.24 + resolution: "@radix-ui/react-menu@npm:2.1.24" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-collection": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-dismissable-layer": "npm:1.1.19" + "@radix-ui/react-focus-guards": "npm:1.1.6" + "@radix-ui/react-focus-scope": "npm:1.1.16" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-popper": "npm:1.3.7" + "@radix-ui/react-portal": "npm:1.1.17" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-roving-focus": "npm:1.1.19" + "@radix-ui/react-slot": "npm:1.3.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.7.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/01fd48bf24c4a43e799a9f0d4b4dc5b2bde57cd56307d0bb179c97dab99b902f5edb8f48899c21b623bcdfbc80d59e8a9ca5670b86c40f0b8ca4b90712bc3bcc + languageName: node + linkType: hard + +"@radix-ui/react-menubar@npm:1.1.24": + version: 1.1.24 + resolution: "@radix-ui/react-menubar@npm:1.1.24" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-collection": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-menu": "npm:2.1.24" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-roving-focus": "npm:1.1.19" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/317e82e075b529e7910a1431843c9fd22d4befb0a2359a617e31e6acdac2a6a79f5ddc71534cce136219402d134327537822a65c6c3c6c51f25401156e5b0736 + languageName: node + linkType: hard + +"@radix-ui/react-navigation-menu@npm:1.2.22": + version: 1.2.22 + resolution: "@radix-ui/react-navigation-menu@npm:1.2.22" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-collection": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-dismissable-layer": "npm:1.1.19" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + "@radix-ui/react-use-previous": "npm:1.1.4" + "@radix-ui/react-visually-hidden": "npm:1.2.11" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/419ec8716cb17ce472e9771086666777b7740f471605c594fcf2ff5aafdf516b7d538179225e986b6a34381b959c56af28893a50221ca2500d298d6ab7ad7c11 + languageName: node + linkType: hard + +"@radix-ui/react-one-time-password-field@npm:0.1.16": + version: 0.1.16 + resolution: "@radix-ui/react-one-time-password-field@npm:0.1.16" + dependencies: + "@radix-ui/number": "npm:1.1.3" + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-collection": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-roving-focus": "npm:1.1.19" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-effect-event": "npm:0.0.5" + "@radix-ui/react-use-is-hydrated": "npm:0.1.3" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/a7d8baaa58a8e25e80cbea55ef568d43b9529c09a3c490216d037a305e98c5487c216ac2037cd727a6b1b1f893fade3aefebeefbbc62779a8767524d73171cc4 + languageName: node + linkType: hard + +"@radix-ui/react-password-toggle-field@npm:0.1.11": + version: 0.1.11 + resolution: "@radix-ui/react-password-toggle-field@npm:0.1.11" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-effect-event": "npm:0.0.5" + "@radix-ui/react-use-is-hydrated": "npm:0.1.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/22b461393788dc6c68697661639609016d86555deaeb175df28249f72b14d1c0501c57c83063198764319a6c7185cc378dc3b2f3d31950615facd264546f57ee + languageName: node + linkType: hard + +"@radix-ui/react-popover@npm:1.1.23": + version: 1.1.23 + resolution: "@radix-ui/react-popover@npm:1.1.23" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-dismissable-layer": "npm:1.1.19" + "@radix-ui/react-focus-guards": "npm:1.1.6" + "@radix-ui/react-focus-scope": "npm:1.1.16" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-popper": "npm:1.3.7" + "@radix-ui/react-portal": "npm:1.1.17" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-slot": "npm:1.3.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.7.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/b0a6c89657c7aaa379f6e4361ebb821b50cb526ff8ca287451360917ab31a9682165b8b82d2f9623dc98aeb0be9090e386961deac19d50c8f37958745483bd3b + languageName: node + linkType: hard + +"@radix-ui/react-popper@npm:1.3.7": + version: 1.3.7 + resolution: "@radix-ui/react-popper@npm:1.3.7" + dependencies: + "@floating-ui/react-dom": "npm:^2.0.0" + "@radix-ui/react-arrow": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + "@radix-ui/react-use-rect": "npm:1.1.4" + "@radix-ui/react-use-size": "npm:1.1.4" + "@radix-ui/rect": "npm:1.1.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/f32e85f70d2161d724df4adae4103d5c1f61ff344d25ad51c4f3d0a02b6251890b864dde3313093d748efe824dc9cceb40a068ac68b10bd1163d4b678e01d6e3 + languageName: node + linkType: hard + +"@radix-ui/react-portal@npm:1.1.17": + version: 1.1.17 + resolution: "@radix-ui/react-portal@npm:1.1.17" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/f3358ef906582ee8dd8ae62db1f1c1e9810e0554ff8e1a887c731fb35d3b673791e9d338cab884f3eddd74e24afd8e21679b1cc24fd035c68ad71dd09ed71bae + languageName: node + linkType: hard + +"@radix-ui/react-presence@npm:1.1.10": + version: 1.1.10 + resolution: "@radix-ui/react-presence@npm:1.1.10" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/5ef8048ea02ecfd11284e229efa33f75074189788b68edb9fe38140cbc56cc40ee77f017f34e3c3d3a5dbc1fb12b5a18d5a9ef2a84ef966a1d90502be5a84617 + languageName: node + linkType: hard + +"@radix-ui/react-primitive@npm:2.1.10, @radix-ui/react-primitive@npm:^2.0.2": + version: 2.1.10 + resolution: "@radix-ui/react-primitive@npm:2.1.10" + dependencies: + "@radix-ui/react-slot": "npm:1.3.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/3be05e842d20a5eb08cebbc1d4d08f24a4c09af8cb864e1d1dddf27f362b739ce0edc1c9fc188a7c734cecd8f5df21e379c15774230f4ab11ce2a8229d3598da + languageName: node + linkType: hard + +"@radix-ui/react-progress@npm:1.1.16": + version: 1.1.16 + resolution: "@radix-ui/react-progress@npm:1.1.16" + dependencies: + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-primitive": "npm:2.1.10" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/250f9e888d252473d8a56097ec98e5564157baffc93de66c870ee59e92365fbcca841efe130e0a01fcc5863ee0b56a137fbb82f1aee12987202846c251a22852 + languageName: node + linkType: hard + +"@radix-ui/react-radio-group@npm:1.4.7": + version: 1.4.7 + resolution: "@radix-ui/react-radio-group@npm:1.4.7" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-roving-focus": "npm:1.1.19" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-size": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/ffcff65157e3f736aa79e47f29b36075c320e2f888b80d11a1fd79947d1b947fe9bacd6372e17b7fd6827e53157083e8ec315aca1798bbdb5e3ced0b4e3a3625 + languageName: node + linkType: hard + +"@radix-ui/react-roving-focus@npm:1.1.19": + version: 1.1.19 + resolution: "@radix-ui/react-roving-focus@npm:1.1.19" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-collection": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-is-hydrated": "npm:0.1.3" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/b11083f923635d4cddd5a45c5145e8684ac8311b7a9e3970cb60a0a4f5c1ab692b502a732f02f716560e1308c2d5e9b6925fe8a9479d7aadfe0f6156809eeafb + languageName: node + linkType: hard + +"@radix-ui/react-scroll-area@npm:1.2.18": + version: 1.2.18 + resolution: "@radix-ui/react-scroll-area@npm:1.2.18" + dependencies: + "@radix-ui/number": "npm:1.1.3" + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/1b0adc53f6df36e79d78aa828bd00a94101ceb7be1461d0ad67de4e941fc06d05f590f81251bb3efa153bc0f0b6d402f16de5b1d7c3f208d8523feda5b305fb8 + languageName: node + linkType: hard + +"@radix-ui/react-select@npm:2.3.7": + version: 2.3.7 + resolution: "@radix-ui/react-select@npm:2.3.7" + dependencies: + "@radix-ui/number": "npm:1.1.3" + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-collection": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-dismissable-layer": "npm:1.1.19" + "@radix-ui/react-focus-guards": "npm:1.1.6" + "@radix-ui/react-focus-scope": "npm:1.1.16" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-popper": "npm:1.3.7" + "@radix-ui/react-portal": "npm:1.1.17" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-slot": "npm:1.3.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + "@radix-ui/react-use-previous": "npm:1.1.4" + "@radix-ui/react-visually-hidden": "npm:1.2.11" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.7.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/122316803c98b6c49bc0c2f7207e5d1b129ed886f1d085feee53fb9640550d604ae9328783c2f0837e4f99776adfe728f4296affd4661c2efb12be12a7db4830 + languageName: node + linkType: hard + +"@radix-ui/react-separator@npm:1.1.15": + version: 1.1.15 + resolution: "@radix-ui/react-separator@npm:1.1.15" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.10" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/72c9142ad39ca620985a98eb7bc1e2a4edc7d75246003ff320e29855919b84de68c3705d68f29bb5fc51f616ddaa9265360dec7b972359d6c2422dd719bde818 + languageName: node + linkType: hard + +"@radix-ui/react-slider@npm:1.4.7": + version: 1.4.7 + resolution: "@radix-ui/react-slider@npm:1.4.7" + dependencies: + "@radix-ui/number": "npm:1.1.3" + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-collection": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + "@radix-ui/react-use-previous": "npm:1.1.4" + "@radix-ui/react-use-size": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/f21d23f19958aba75c2715da67f1c5d2f13d02675550b1fda572b2e35d14a32d9c3e11b46e884bf9725a9013bdd40a428a838bed9d504f616268baf6ed27381b + languageName: node + linkType: hard + +"@radix-ui/react-slot@npm:1.3.3": + version: 1.3.3 + resolution: "@radix-ui/react-slot@npm:1.3.3" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.5" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/7ed356819bbb40415ccd16bc8a8da3162966e827ea8fa404c2ae547518777aa193b0aad07f139f3c9d3280f51433e1cc4fa4cfd8dc7e8e6215e7b0d7a25e4b39 + languageName: node + linkType: hard + +"@radix-ui/react-switch@npm:1.3.7": + version: 1.3.7 + resolution: "@radix-ui/react-switch@npm:1.3.7" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-size": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/cc2d3670f78a14eb558a50b9979568ac2439551b4487cbf83f25b7f5cd2859d29672fab7fc8c97e16b9ee9420b4fd0a103f1bd409bb172a14ee8528aaec59fff + languageName: node + linkType: hard + +"@radix-ui/react-tabs@npm:1.1.21": + version: 1.1.21 + resolution: "@radix-ui/react-tabs@npm:1.1.21" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-roving-focus": "npm:1.1.19" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/9ed747ec592b04efc57bc1b124b9b4b4bb80011167ea5d87ea99955a88fa3a84df2e78a8ffaa1a15e2ba3d9598b6d8a8c28664fc3712aa8627c0e37267389652 + languageName: node + linkType: hard + +"@radix-ui/react-toast@npm:1.2.23": + version: 1.2.23 + resolution: "@radix-ui/react-toast@npm:1.2.23" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-collection": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-dismissable-layer": "npm:1.1.19" + "@radix-ui/react-portal": "npm:1.1.17" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + "@radix-ui/react-visually-hidden": "npm:1.2.11" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/03c620101417b9641ed9ffc888966bd772e1c845f58b1b7b673165f7e886c3602af26eb55965e71513ab9be82dbc0a5b49c95004205b178256d12873733d29d0 + languageName: node + linkType: hard + +"@radix-ui/react-toggle-group@npm:1.1.19": + version: 1.1.19 + resolution: "@radix-ui/react-toggle-group@npm:1.1.19" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-roving-focus": "npm:1.1.19" + "@radix-ui/react-toggle": "npm:1.1.18" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/156a3f3aad5eae35f31d4631c92bdd5bd663b34c5cce3aaa184166aaa67d73960dc6a04f6bb2242ae2453d8dd088a89799861f57174a815b36174e75c2c916e5 + languageName: node + linkType: hard + +"@radix-ui/react-toggle@npm:1.1.18": + version: 1.1.18 + resolution: "@radix-ui/react-toggle@npm:1.1.18" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/9afd26c680271841ccc071bb1dfdf0fc2027d3d110dc501306826c3807bb3d8ed708772ea9fbcf713a7f9d186975062a370559e29af40f1ed29e3a49119077c0 + languageName: node + linkType: hard + +"@radix-ui/react-toolbar@npm:1.1.19": + version: 1.1.19 + resolution: "@radix-ui/react-toolbar@npm:1.1.19" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-roving-focus": "npm:1.1.19" + "@radix-ui/react-separator": "npm:1.1.15" + "@radix-ui/react-toggle-group": "npm:1.1.19" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/2bb5970b0f1bc71eb2547f230b5c5a8cfcae1d4d803ec8a80f5ad0b5dfc38716768287d46096086e37b0654208d43eba774fc8983562bfeaea905cb3629c04c3 + languageName: node + linkType: hard + +"@radix-ui/react-tooltip@npm:1.2.16": + version: 1.2.16 + resolution: "@radix-ui/react-tooltip@npm:1.2.16" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-dismissable-layer": "npm:1.1.19" + "@radix-ui/react-id": "npm:1.1.4" + "@radix-ui/react-popper": "npm:1.3.7" + "@radix-ui/react-portal": "npm:1.1.17" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-slot": "npm:1.3.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + "@radix-ui/react-visually-hidden": "npm:1.2.11" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/4ddd4051433c11172218971b2788ca34d53440db7e78e2144107b09e500bbac52893b534ab23c85fd5ae4e68f1d7c750c7987033c7e167c804b2aa0047b50cb8 + languageName: node + linkType: hard + +"@radix-ui/react-use-callback-ref@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-use-callback-ref@npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/d5cc1248ab6a58b12eefd3115c98fb438b74b2e6dec463d2e097d27df8e9f40b9d8693ee10a422da4f7a688fbe52c62cc2fe3aa5972020b9425b86a2a28f5f01 + languageName: node + linkType: hard + +"@radix-ui/react-use-controllable-state@npm:1.2.6": + version: 1.2.6 + resolution: "@radix-ui/react-use-controllable-state@npm:1.2.6" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-use-effect-event": "npm:0.0.5" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/1672c3fa5c1f2dbc354db4d403523eef15bcbe24448b911e67e3e95f351b72ffc1dc1021a225d98116cd78d1c96e6280f1645ac6d6b9930df46b180b8a6f11a5 + languageName: node + linkType: hard + +"@radix-ui/react-use-effect-event@npm:0.0.5": + version: 0.0.5 + resolution: "@radix-ui/react-use-effect-event@npm:0.0.5" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/36eb3523d3c30e88615e0492aed5c644e61eb4fec98097de0fb2ee1cf33d7684b76e8a34bedb0aa7c25b412e0ef56c83b16772e9716d03d61901bc1b98cb1993 + languageName: node + linkType: hard + +"@radix-ui/react-use-escape-keydown@npm:1.1.5": + version: 1.1.5 + resolution: "@radix-ui/react-use-escape-keydown@npm:1.1.5" + dependencies: + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/32f3f948c053bc3def7b74282aac497f45ee738ff456011b73b3154871ddcb935f50f6e8b866498f40de74831e0ac8ebec90893e8b1fe0a3e41de5de0844c397 + languageName: node + linkType: hard + +"@radix-ui/react-use-is-hydrated@npm:0.1.3": + version: 0.1.3 + resolution: "@radix-ui/react-use-is-hydrated@npm:0.1.3" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/ec5d8cd7dcb825d23c9e622cb78605242ae6ae5744549f4713fd543a2a2003c356031bac3bd8db353579e6cd9f71798b59915f54de7b78b442e847024f2a5ff9 + languageName: node + linkType: hard + +"@radix-ui/react-use-layout-effect@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-use-layout-effect@npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/427df10bb3c55de36afd6b9a749fb4b72b0f78840ae4abb5dda24aa09d653ede2d264c96a8030f58db353498f42b2bce929510c777004e250f7845b06c782bcf + languageName: node + linkType: hard + +"@radix-ui/react-use-previous@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-use-previous@npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/e291d635ef2ec1813ad499aa4d14b6da81042831549140e8a71ab982540322a9abbe629ebffb175258b9c60e77fd0736b9b4fe1cd76066912fd5270783030778 + languageName: node + linkType: hard + +"@radix-ui/react-use-rect@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-use-rect@npm:1.1.4" + dependencies: + "@radix-ui/rect": "npm:1.1.3" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/3acfe9265f9fbdcdead05ec4540a95d8932fb53e9eabd73c080650122424d3d5247260d349ea2d91b2712af9670a27ac2110400671b98d061b2347390fe29490 + languageName: node + linkType: hard + +"@radix-ui/react-use-size@npm:1.1.4": + version: 1.1.4 + resolution: "@radix-ui/react-use-size@npm:1.1.4" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/ebb4a0c6e71768ae231635cc721d7f01de0a31637e73cccea095ad63921bfece9635eb9500cbe32504585380910f578216fb3eebdfc21b511f6dd6df81659f75 + languageName: node + linkType: hard + +"@radix-ui/react-visually-hidden@npm:1.2.11": + version: 1.2.11 + resolution: "@radix-ui/react-visually-hidden@npm:1.2.11" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.10" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/053fe28a279d37ad826970ffcaf93bdf029831e7aba8f34e0cdeecbd45a360b56c2f548b49e8a6bcbf484ceb3389bee2642dece754e59d63c14fb36c1a36a1a4 + languageName: node + linkType: hard + +"@radix-ui/rect@npm:1.1.3": + version: 1.1.3 + resolution: "@radix-ui/rect@npm:1.1.3" + checksum: 10c0/9396d6c4dbd8da51c0bef7481e1e46e7f371e27cc6a55c1079e854f3aa9f7dd89e02d98cc36e861e8d43fbb775eb76b06bdf5879fa12f25116bea676af2926da + languageName: node + linkType: hard + +"@remixicon/react@npm:^4.9.0": + version: 4.9.0 + resolution: "@remixicon/react@npm:4.9.0" + peerDependencies: + react: ">=18.2.0" + checksum: 10c0/91bbadb677f45cf8b6687fdf9d273511fddb4e862f1da6c11bc7932acb578bcb26ae5b9e1eb62143a4e916b71797739a36d689866d5b0949f0f0f22c1684cb96 + languageName: node + linkType: hard + +"@rolldown/binding-android-arm-eabi@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-android-arm-eabi@npm:1.2.12" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-android-arm64@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-android-arm64@npm:1.2.12" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-darwin-arm64@npm:1.2.12" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-darwin-x64@npm:1.2.12" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-freebsd-x64@npm:1.2.12" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.12" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.12" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.12" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.12" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.12" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.12" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.12" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.12" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.12" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.2.12": + version: 1.2.12 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.12" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:^1.0.0, @rolldown/pluginutils@npm:^1.0.1": + version: 1.0.1 + resolution: "@rolldown/pluginutils@npm:1.0.1" + checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd + languageName: node + linkType: hard + +"@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607": + version: 0.0.0-preview-116d5d7-20260730141607 + resolution: "@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607" + dependencies: + gql.tada: "npm:1.9.2" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + vue: ^3.5.0 + peerDependenciesMeta: + react: + optional: true + vue: + optional: true + bin: + hydrogen: bin/hydrogen.mjs + checksum: 10c0/b12e230d174a910d8abb7ab18ecb99357dce82d2125054b30af6dcbecf280c6fea40c8ca5fd67d8931e1079fd73e587e3ea1484f50c294f4a79ad714c9664ecd + languageName: node + linkType: hard + +"@tailwindcss/node@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/node@npm:4.3.3" + dependencies: + "@jridgewell/remapping": "npm:^2.3.5" + enhanced-resolve: "npm:^5.24.1" + jiti: "npm:^2.7.0" + lightningcss: "npm:1.32.0" + magic-string: "npm:^0.30.21" + source-map-js: "npm:^1.2.1" + tailwindcss: "npm:4.3.3" + checksum: 10c0/0123669be5b3e78d42b0e10cb34d547fb8574c8e7dc7a97a141c8a4ab4215852b5b1b99b97763e898e1da1493ee8424cb72e28225cc4876828c95156f94d7f0e + languageName: node + linkType: hard + +"@tailwindcss/oxide-android-arm64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-android-arm64@npm:4.3.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-darwin-arm64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.3.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-darwin-x64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-darwin-x64@npm:4.3.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-freebsd-x64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.3.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm64-musl@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.3.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-x64-gnu@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.3.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-x64-musl@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.3.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@tailwindcss/oxide-wasm32-wasi@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.3.3" + dependencies: + "@emnapi/core": "npm:^1.11.1" + "@emnapi/runtime": "npm:^1.11.1" + "@emnapi/wasi-threads": "npm:^1.2.2" + "@napi-rs/wasm-runtime": "npm:^1.1.4" + "@tybys/wasm-util": "npm:^0.10.2" + tslib: "npm:^2.8.1" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-win32-x64-msvc@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.3.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide@npm:4.3.3" + dependencies: + "@tailwindcss/oxide-android-arm64": "npm:4.3.3" + "@tailwindcss/oxide-darwin-arm64": "npm:4.3.3" + "@tailwindcss/oxide-darwin-x64": "npm:4.3.3" + "@tailwindcss/oxide-freebsd-x64": "npm:4.3.3" + "@tailwindcss/oxide-linux-arm-gnueabihf": "npm:4.3.3" + "@tailwindcss/oxide-linux-arm64-gnu": "npm:4.3.3" + "@tailwindcss/oxide-linux-arm64-musl": "npm:4.3.3" + "@tailwindcss/oxide-linux-x64-gnu": "npm:4.3.3" + "@tailwindcss/oxide-linux-x64-musl": "npm:4.3.3" + "@tailwindcss/oxide-wasm32-wasi": "npm:4.3.3" + "@tailwindcss/oxide-win32-arm64-msvc": "npm:4.3.3" + "@tailwindcss/oxide-win32-x64-msvc": "npm:4.3.3" + dependenciesMeta: + "@tailwindcss/oxide-android-arm64": + optional: true + "@tailwindcss/oxide-darwin-arm64": + optional: true + "@tailwindcss/oxide-darwin-x64": + optional: true + "@tailwindcss/oxide-freebsd-x64": + optional: true + "@tailwindcss/oxide-linux-arm-gnueabihf": + optional: true + "@tailwindcss/oxide-linux-arm64-gnu": + optional: true + "@tailwindcss/oxide-linux-arm64-musl": + optional: true + "@tailwindcss/oxide-linux-x64-gnu": + optional: true + "@tailwindcss/oxide-linux-x64-musl": + optional: true + "@tailwindcss/oxide-wasm32-wasi": + optional: true + "@tailwindcss/oxide-win32-arm64-msvc": + optional: true + "@tailwindcss/oxide-win32-x64-msvc": + optional: true + checksum: 10c0/6c38b25226dad252347451ed8953a998432b51f1e068b25e021215b886ccf8bcf3af58faf5f4ab50368091e657ca2fa341df1deca6101651eddb567122f2acb5 + languageName: node + linkType: hard + +"@tailwindcss/vite@npm:^4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/vite@npm:4.3.3" + dependencies: + "@tailwindcss/node": "npm:4.3.3" + "@tailwindcss/oxide": "npm:4.3.3" + tailwindcss: "npm:4.3.3" + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + checksum: 10c0/bb1fe12f7a0bb8f6d6142f6bb2e96060ee853902314ef838e8d7f50cf30cc4b07adb7c2c86be2164e0d62eb581b8cc4df2d9aa13b4449f597a67f73c5772d549 + languageName: node + linkType: hard + +"@tanstack/history@npm:1.162.4": + version: 1.162.4 + resolution: "@tanstack/history@npm:1.162.4" + checksum: 10c0/3a8bbe096e597f7945ac85f9da6b1bbd9f62c9df34c29526a3667189d715bb434fd077186333025a78ef6900adb943816cc58cc90b015527d4a916baedb09e44 + languageName: node + linkType: hard + +"@tanstack/query-core@npm:5.104.1": + version: 5.104.1 + resolution: "@tanstack/query-core@npm:5.104.1" + checksum: 10c0/d42788e0c9ee4a389d7de21c5fe856acd50d24f72cc87b3c76e6852399ab9835801f415d0a9e10706d2a3a27937d42c5f1ac621ceeac21e6bcd60aa87984bdf4 + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^5.104.1": + version: 5.104.1 + resolution: "@tanstack/react-query@npm:5.104.1" + dependencies: + "@tanstack/query-core": "npm:5.104.1" + peerDependencies: + react: ^18 || ^19 + checksum: 10c0/b5d431f983ff3dbd91c14eb2e3ec2dc0a429ab0b8b7dd6169a5b021eb450fffe1f2f1d0bef0726914b63d5b264b699d2ae1b4a2381bff85bb9c1c4c070407b74 + languageName: node + linkType: hard + +"@tanstack/react-router@npm:^1.170.41": + version: 1.170.41 + resolution: "@tanstack/react-router@npm:1.170.41" + dependencies: + "@tanstack/history": "npm:1.162.4" + "@tanstack/react-store": "npm:^0.11.2" + "@tanstack/router-core": "npm:1.171.34" + isbot: "npm:^5.1.22" + peerDependencies: + react: ">=18.0.0 || >=19.0.0" + react-dom: ">=18.0.0 || >=19.0.0" + checksum: 10c0/38379f90199dbae07c73c0199508cec2524b60ffe9d0b91c46d674cb5c2a3392a223f9a2a0dda964687cfa9925dbff77a64a18cb7afe105ed25894444e313099 + languageName: node + linkType: hard + +"@tanstack/react-store@npm:^0.11.2": + version: 0.11.2 + resolution: "@tanstack/react-store@npm:0.11.2" + dependencies: + "@tanstack/store": "npm:0.11.2" + use-sync-external-store: "npm:^1.6.0" + 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 + checksum: 10c0/a755aa369c13824709ca5b29355ab7a72ba913d3561786f993412c8c375f36c54b20c0366bd406cf21b363b12e516a251a774ee7140479571b628e895448a93d + languageName: node + linkType: hard + +"@tanstack/router-core@npm:1.171.34": + version: 1.171.34 + resolution: "@tanstack/router-core@npm:1.171.34" + dependencies: + "@tanstack/history": "npm:1.162.4" + cookie-es: "npm:^3.0.0" + seroval: "npm:^1.6.7" + seroval-plugins: "npm:^1.6.7" + checksum: 10c0/d1d0154f9979b892e21ef73293805bf96e1b0136147991f7687b67ffcf268cd40364295fc8f0f026e6f6ac375378959045a16b9b1d889180960f2fa9e4894ce2 + languageName: node + linkType: hard + +"@tanstack/router-generator@npm:1.167.40": + version: 1.167.40 + resolution: "@tanstack/router-generator@npm:1.167.40" + dependencies: + "@babel/types": "npm:^7.29.8" + "@tanstack/router-core": "npm:1.171.34" + "@tanstack/router-utils": "npm:1.162.3" + "@tanstack/virtual-file-routes": "npm:1.162.0" + jiti: "npm:^2.7.0" + magic-string: "npm:^0.30.21" + prettier: "npm:^3.9.6" + zod: "npm:^4.5.4" + checksum: 10c0/c396907e644ba57a37c9bbcc9db005bcc889adac844567ea356f2128414663f16d324214594bd419bb43fbfbe9b6e2b67caa2481b149d299b54050a107073326 + languageName: node + linkType: hard + +"@tanstack/router-plugin@npm:^1.168.42": + version: 1.168.42 + resolution: "@tanstack/router-plugin@npm:1.168.42" + dependencies: + "@babel/core": "npm:^7.29.7" + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.8" + "@tanstack/router-core": "npm:1.171.34" + "@tanstack/router-generator": "npm:1.167.40" + "@tanstack/router-utils": "npm:1.162.3" + chokidar: "npm:^5.0.0" + unplugin: "npm:^3.3.0" + zod: "npm:^4.5.4" + peerDependencies: + "@rsbuild/core": ">=1.0.2 || ^2.0.0" + "@tanstack/react-router": ^1.170.41 + vite: ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0" + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 + webpack: ">=5.92.0" + peerDependenciesMeta: + "@rsbuild/core": + optional: true + "@tanstack/react-router": + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + checksum: 10c0/d91f749ceb3f32676c7ffb21f4af2cb7446a3734341b570c15404b0ce6e58cf2572290119cee023cd84b86a2176abb2d95f60f7eaf6d02374b189284c996e877 + languageName: node + linkType: hard + +"@tanstack/router-utils@npm:1.162.3": + version: 1.162.3 + resolution: "@tanstack/router-utils@npm:1.162.3" + dependencies: + "@babel/generator": "npm:^7.29.8" + "@babel/parser": "npm:^7.29.8" + "@babel/types": "npm:^7.29.8" + ansis: "npm:^4.3.1" + babel-dead-code-elimination: "npm:^1.0.12" + diff: "npm:^8.0.4" + pathe: "npm:^2.0.3" + tinyglobby: "npm:^0.2.17" + checksum: 10c0/ecdd365d0a065f337139361cba491c435047fdbbfece8f2c57b292c07fe2a7b8973f3e35af8cff856aff131841ddf526962754fdec917f866432285d555ec34f + languageName: node + linkType: hard + +"@tanstack/store@npm:0.11.2": + version: 0.11.2 + resolution: "@tanstack/store@npm:0.11.2" + checksum: 10c0/5422149c853c16e642a0ddfb51c2b4a867a11516069d3b9647659212e9762b86d609144de6ac6ec099b0360c3b198d1243f613148d69c1028dee56382f0430cd + languageName: node + linkType: hard + +"@tanstack/virtual-file-routes@npm:1.162.0": + version: 1.162.0 + resolution: "@tanstack/virtual-file-routes@npm:1.162.0" + checksum: 10c0/4c7f36e792b71935553e9e7dfa536e072417f5794074a20f79feef63d9745590b15e248162182a070de9033fa4193c18b98cc4c5d5c04e3df9db15a4ba2aae2e + languageName: node + linkType: hard + +"@tybys/wasm-util@npm:^0.10.2, @tybys/wasm-util@npm:^0.10.3": + version: 0.10.4 + resolution: "@tybys/wasm-util@npm:0.10.4" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/27947f254efa3f2bc41f47040f57f41c246200c6439a9fd54c69f50940e320022c320275f138e30b543ee81ce69593905dfa7ba0831577ab636f829aafa70701 + languageName: node + linkType: hard + +"@types/node@npm:^26.6.4": + version: 26.6.4 + resolution: "@types/node@npm:26.6.4" + dependencies: + undici-types: "npm:~8.9.0" + checksum: 10c0/094e7362df9200ba4c2da09131ca3b5d1aa51535a268a24e4ee2546d9c5a323d1d30cbdaaaa3db7ef8b2387572d1f9edabb11538ab7a09d3bca33ae5cddc2b1b + languageName: node + linkType: hard + +"@types/react-dom@npm:^19.3.0": + version: 19.3.0 + resolution: "@types/react-dom@npm:19.3.0" + peerDependencies: + "@types/react": ^19.3.0 + checksum: 10c0/ce0066d28b62bc6afdd17ca84756b8fbe0f6ffc0c8b1e60739abc30e81510da2edbc1007e6ab3d974d30f5a8c25bb0f7de8d7ce09736c909fe84c0f9aed0b946 + languageName: node + linkType: hard + +"@types/react@npm:^19.3.0": + version: 19.3.0 + resolution: "@types/react@npm:19.3.0" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/fbcabf303f935ca9f36ed2dcf81751cf84744098c5a683c8a62a0fa7edd31714b48d5c46b12fd0014f7f2dfe4d9ec6de428a850748eec719a70294e51029551b + languageName: node + linkType: hard + +"@vitejs/plugin-react@npm:^6.1.1": + version: 6.1.1 + resolution: "@vitejs/plugin-react@npm:6.1.1" + dependencies: + "@rolldown/pluginutils": "npm:^1.0.1" + peerDependencies: + "@rolldown/plugin-babel": ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + "@rolldown/plugin-babel": + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + checksum: 10c0/4599b37ee19ebee289782b177642e3002afa9b74cb57a2c0e2a601053c7ebb47f9ef178ab605ff1217de1d23069948a8d366447bfac0d9fb2f17afee5368acd0 + languageName: node + linkType: hard + +"abbrev@npm:^5.0.0": + version: 5.0.0 + resolution: "abbrev@npm:5.0.0" + checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5 + languageName: node + linkType: hard + +"ansis@npm:^4.3.1": + version: 4.4.0 + resolution: "ansis@npm:4.4.0" + checksum: 10c0/5b7a0d61538e21fbee0b5980e5cb97b2933cef46f749e8f8d75af445754d4153267d0c957a65a9d54ef976a949b69eb66dc201c44b0795128d881c2ccacfb310 + languageName: node + linkType: hard + +"aria-hidden@npm:^1.2.4": + version: 1.2.6 + resolution: "aria-hidden@npm:1.2.6" + dependencies: + tslib: "npm:^2.0.0" + checksum: 10c0/7720cb539497a9f760f68f98a4b30f22c6767aa0e72fa7d58279f7c164e258fc38b2699828f8de881aab0fc8e9c56d1313a3f1a965046fc0381a554dbc72b54a + languageName: node + linkType: hard + +"babel-dead-code-elimination@npm:^1.0.12": + version: 1.0.12 + resolution: "babel-dead-code-elimination@npm:1.0.12" + dependencies: + "@babel/core": "npm:^7.23.7" + "@babel/parser": "npm:^7.23.6" + "@babel/traverse": "npm:^7.23.7" + "@babel/types": "npm:^7.23.6" + checksum: 10c0/9289b66ce202a5f2b8c160f6a0ed16b97c42a9e06e92ea997df4e34d6a8309a01cf641b21467ee6a2713efd7e158b4b770e04a07f3f9d30eb05d428d186f7a60 + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee + languageName: node + linkType: hard + +"baseline-browser-mapping@npm:^2.11.26": + version: 2.11.27 + resolution: "baseline-browser-mapping@npm:2.11.27" + bin: + baseline-browser-mapping: dist/cli.cjs + checksum: 10c0/4a06cfab5ad4f57633c0092340af41388f3fc71e6a91a3309942a23c60b65e9ec6f243edbd2f484d3d0ee40f5f33d974c8a85c9c207e104ea17ba429cfb4d3df + languageName: node + linkType: hard + +"brace-expansion@npm:^2.0.2": + version: 2.1.7 + resolution: "brace-expansion@npm:2.1.7" + dependencies: + balanced-match: "npm:^1.0.0" + checksum: 10c0/17ee4194c85b05cde0a7b7c068eeef664440c027116ef42c3d35c13ebabdc9fd3eaf05c5c8a06dd0ca20ae012d652561d8ac2a699daa5cbee1f0510e5134ab4d + languageName: node + linkType: hard + +"browserslist@npm:^4.24.0": + version: 4.29.3 + resolution: "browserslist@npm:4.29.3" + dependencies: + baseline-browser-mapping: "npm:^2.11.26" + caniuse-lite: "npm:^1.0.30001813" + electron-to-chromium: "npm:^1.5.439" + node-releases: "npm:^2.0.57" + update-browserslist-db: "npm:^1.3.3" + bin: + browserslist: cli.js + checksum: 10c0/6e1a1fcae77369260e936fe63e7994105f04c6b7aea4f2139fdf3ecccfcb1eb24d4522c2828c6ed48ef71206a33f4346e11cf714c5aac0f7496cead06bb66bac + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.30001813": + version: 1.0.30001814 + resolution: "caniuse-lite@npm:1.0.30001814" + checksum: 10c0/c1b93b8cd43528241e1a97d0a683ad0cbbf825a2d5a6a021ac1abaee966a861095d2bbffa9903638ee590534a243b024f18d7440e514d0f9c2bb90cfcf4651fb + languageName: node + linkType: hard + +"chokidar@npm:^5.0.0": + version: 5.0.0 + resolution: "chokidar@npm:5.0.0" + dependencies: + readdirp: "npm:^5.0.0" + checksum: 10c0/42fc907cb2a7ff5c9e220f84dae75380a77997f851c2a5e7865a2cf9ae45dd407a23557208cdcdbf3ac8c93341135a1748e4c48c31855f3bfa095e5159b6bdec + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"class-variance-authority@npm:^0.7.1": + version: 0.7.1 + resolution: "class-variance-authority@npm:0.7.1" + dependencies: + clsx: "npm:^2.1.1" + checksum: 10c0/0f438cea22131808b99272de0fa933c2532d5659773bfec0c583de7b3f038378996d3350683426b8e9c74a6286699382106d71fbec52f0dd5fbb191792cccb5b + languageName: node + linkType: hard + +"clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + +"cmdk@npm:^1.1.1": + version: 1.1.1 + resolution: "cmdk@npm:1.1.1" + dependencies: + "@radix-ui/react-compose-refs": "npm:^1.1.1" + "@radix-ui/react-dialog": "npm:^1.1.6" + "@radix-ui/react-id": "npm:^1.1.0" + "@radix-ui/react-primitive": "npm:^2.0.2" + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + checksum: 10c0/5605ac4396ec9bc65c82f954da19dd89a0636a54026df72780e2470da1381f9d57434a80a53f2d57eaa4e759660a3ebba9232b74258dc09970576591eae03116 + languageName: node + linkType: hard + +"convert-source-map@npm:^2.0.0": + version: 2.0.0 + resolution: "convert-source-map@npm:2.0.0" + checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b + languageName: node + linkType: hard + +"cookie-es@npm:^3.0.0": + version: 3.1.1 + resolution: "cookie-es@npm:3.1.1" + checksum: 10c0/62cf0c325cc547b52477b351e9b5d068b3ffc74f7d143cdf7af854648dd1015fca3aed1498b355365e24b0746333f0b15688ed3a535abecc5ed0a046ae956a84 + languageName: node + linkType: hard + +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + +"debug@npm:^4.1.0, debug@npm:^4.3.1": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + +"detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + +"detect-node-es@npm:^1.1.0": + version: 1.1.0 + resolution: "detect-node-es@npm:1.1.0" + checksum: 10c0/e562f00de23f10c27d7119e1af0e7388407eb4b06596a25f6d79a360094a109ff285de317f02b090faae093d314cf6e73ac3214f8a5bb3a0def5bece94557fbe + languageName: node + linkType: hard + +"diff@npm:^8.0.4": + version: 8.0.4 + resolution: "diff@npm:8.0.4" + checksum: 10c0/7ee5d03926db4039be7252ac3b0abaae1bd122a2ca971e5ca7270e444e36ff83dd906fad1a719740ca347e97ed5dc8f458a76a8391dbcd7aff363bdafb348a00 + languageName: node + linkType: hard + +"electron-to-chromium@npm:^1.5.439": + version: 1.5.444 + resolution: "electron-to-chromium@npm:1.5.444" + checksum: 10c0/ad8e61961707a2e95515c121e1f5059c8864c79f7d5612f64eb4065a468c93a83228afa41a1a8877ed762d323908c6189847468dcec9ee1b5276b123d8c08c23 + languageName: node + linkType: hard + +"enhanced-resolve@npm:^5.24.1": + version: 5.26.0 + resolution: "enhanced-resolve@npm:5.26.0" + dependencies: + graceful-fs: "npm:^4.2.4" + tapable: "npm:^2.3.3" + checksum: 10c0/fea1f02aa89793fecfd3ab0764be988fdf10dd5147ef65548726d918e68e3b9cf84224de37387356b54eee69363e4d59d9624dd7be9a23a0b1f568c46fa7a8a2 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"escalade@npm:^3.2.0": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 + languageName: node + linkType: hard + +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"framer-motion@npm:^12.42.2": + version: 12.43.0 + resolution: "framer-motion@npm:12.43.0" + dependencies: + motion-dom: "npm:^12.43.0" + motion-utils: "npm:^12.39.0" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/cce3c1daf8f53da4d9299e6bdd2a1e9f08a30e13d84ce1e468cf25285f9ec2a900c812e0aa3dce79e7453a2a3cf328a717451beef9bf0a6175a3d3794116e36a + languageName: node + linkType: hard + +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"gensync@npm:^1.0.0-beta.2": + version: 1.0.0-beta.2 + resolution: "gensync@npm:1.0.0-beta.2" + checksum: 10c0/782aba6cba65b1bb5af3b095d96249d20edbe8df32dbf4696fd49be2583faf676173bf4809386588828e4dd76a3354fcbeb577bab1c833ccd9fc4577f26103f8 + languageName: node + linkType: hard + +"get-nonce@npm:^1.0.0": + version: 1.0.1 + resolution: "get-nonce@npm:1.0.1" + checksum: 10c0/2d7df55279060bf0568549e1ffc9b84bc32a32b7541675ca092dce56317cdd1a59a98dcc4072c9f6a980779440139a3221d7486f52c488e69dc0fd27b1efb162 + languageName: node + linkType: hard + +"gql.tada@npm:1.9.2": + version: 1.9.2 + resolution: "gql.tada@npm:1.9.2" + dependencies: + "@0no-co/graphql.web": "npm:^1.0.5" + "@0no-co/graphqlsp": "npm:^1.12.13" + "@gql.tada/cli-utils": "npm:1.7.3" + "@gql.tada/internal": "npm:1.0.9" + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + bin: + gql-tada: bin/cli.js + gql.tada: bin/cli.js + checksum: 10c0/e6995d5ddd41a0049f7e2494758d605567027c57b270e59c48355d7fbd0ca95db77db2385c14d2add1198ef59b91c1b5a7f9b70d9eac8668c2be534e55a56517 + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"graphql@npm:^15.5.0 || ^16.0.0 || ^17.0.0": + version: 17.0.2 + resolution: "graphql@npm:17.0.2" + checksum: 10c0/766546216b891439ed09fd8584963df2013e26f2f79f096036eaaa2788c38964e049c8a142d68e3afdabdd8bbe70042639e83f0f4b82edbc7c6850421f072bb6 + languageName: node + linkType: hard + +"hono@npm:^4.13.12": + version: 4.13.12 + resolution: "hono@npm:4.13.12" + checksum: 10c0/6f883c0e7cee9c9e56dff5b6f6aa27208c7e98dc7c74074b0a2d1c10ea524212be32e7157ff12161edbf8905d7aa505307850aef1ba34521da2cfecfcc194e1f + languageName: node + linkType: hard + +"isbot@npm:^5.1.22": + version: 5.2.2 + resolution: "isbot@npm:5.2.2" + checksum: 10c0/df8356dd8745d7fe1922fc8df894bf5560fe959ad7826ac97311c430dc9c6dd628c8aab68d3e45769b50b61411bd4530d874aae07947f6cd04e25206566a7bb3 + languageName: node + linkType: hard + +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce + languageName: node + linkType: hard + +"jiti@npm:^2.7.0": + version: 2.7.0 + resolution: "jiti@npm:2.7.0" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/1b1e2310a490dce1aeea3da5f5dfe18273516c20ce48be2e98eb8ea452d5f3dcc8fd0cfd6d28b4052a24c5dbab6e3089b2d7e79f0bce7915b10d750929563c42 + languageName: node + linkType: hard + +"js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + +"jsesc@npm:^3.0.2": + version: 3.1.0 + resolution: "jsesc@npm:3.1.0" + bin: + jsesc: bin/jsesc + checksum: 10c0/531779df5ec94f47e462da26b4cbf05eb88a83d9f08aac2ba04206508fc598527a153d08bd462bae82fc78b3eaa1a908e1a4a79f886e9238641c4cdefaf118b1 + languageName: node + linkType: hard + +"json5@npm:^2.2.3": + version: 2.2.3 + resolution: "json5@npm:2.2.3" + bin: + json5: lib/cli.js + checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c + languageName: node + linkType: hard + +"lightningcss-android-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-android-arm64@npm:1.32.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-android-arm64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-android-arm64@npm:1.33.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-arm64@npm:1.32.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-darwin-arm64@npm:1.33.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-x64@npm:1.32.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-darwin-x64@npm:1.33.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-freebsd-x64@npm:1.32.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-freebsd-x64@npm:1.33.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.33.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.33.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.33.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.33.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-musl@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-x64-musl@npm:1.33.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.33.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.33.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss@npm:1.32.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.32.0" + lightningcss-darwin-arm64: "npm:1.32.0" + lightningcss-darwin-x64: "npm:1.32.0" + lightningcss-freebsd-x64: "npm:1.32.0" + lightningcss-linux-arm-gnueabihf: "npm:1.32.0" + lightningcss-linux-arm64-gnu: "npm:1.32.0" + lightningcss-linux-arm64-musl: "npm:1.32.0" + lightningcss-linux-x64-gnu: "npm:1.32.0" + lightningcss-linux-x64-musl: "npm:1.32.0" + lightningcss-win32-arm64-msvc: "npm:1.32.0" + lightningcss-win32-x64-msvc: "npm:1.32.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 + languageName: node + linkType: hard + +"lightningcss@npm:^1.33.0": + version: 1.33.0 + resolution: "lightningcss@npm:1.33.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.33.0" + lightningcss-darwin-arm64: "npm:1.33.0" + lightningcss-darwin-x64: "npm:1.33.0" + lightningcss-freebsd-x64: "npm:1.33.0" + lightningcss-linux-arm-gnueabihf: "npm:1.33.0" + lightningcss-linux-arm64-gnu: "npm:1.33.0" + lightningcss-linux-arm64-musl: "npm:1.33.0" + lightningcss-linux-x64-gnu: "npm:1.33.0" + lightningcss-linux-x64-musl: "npm:1.33.0" + lightningcss-win32-arm64-msvc: "npm:1.33.0" + lightningcss-win32-x64-msvc: "npm:1.33.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/ce1f8279fbae636dbf37fa6e7385d5f98ed881d72af3362f24afbd4685e19c1fcdfecf17e5dd77f2ebee3d0c23ade276230d85842d07292229a2cffba8ff20a3 + languageName: node + linkType: hard + +"lru-cache@npm:^5.1.1": + version: 5.1.1 + resolution: "lru-cache@npm:5.1.1" + dependencies: + yallist: "npm:^3.0.2" + checksum: 10c0/89b2ef2ef45f543011e38737b8a8622a2f8998cddf0e5437174ef8f1f70a8b9d14a918ab3e232cb3ba343b7abddffa667f0b59075b2b80e6b4d63c3de6127482 + languageName: node + linkType: hard + +"lucide-react@npm:^0.562.0": + version: 0.562.0 + resolution: "lucide-react@npm:0.562.0" + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/c713a2490916e42c678b9df0d7309d8cdb715af1d7139c7eb49c45547901b276a7cf722548012a668dc0e337eeadf5141d5af8c8fcda6721fc58fb2d288efcba + languageName: node + linkType: hard + +"magic-string@npm:^0.30.21": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + +"minimatch@npm:^9.0.3": + version: 9.0.9 + resolution: "minimatch@npm:9.0.9" + dependencies: + brace-expansion: "npm:^2.0.2" + checksum: 10c0/0b6a58530dbb00361745aa6c8cffaba4c90f551afe7c734830bd95fd88ebf469dd7355a027824ea1d09e37181cfeb0a797fb17df60c15ac174303ac110eb7e86 + languageName: node + linkType: hard + +"minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb + languageName: node + linkType: hard + +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + +"motion-dom@npm:^12.43.0": + version: 12.43.0 + resolution: "motion-dom@npm:12.43.0" + dependencies: + motion-utils: "npm:^12.39.0" + checksum: 10c0/ef7d3f6eaa6cfeb1a939847a8b2581d119f2efc054948921ece0e2b1091f0969c15ca7707b87a46dddac7f156a714878f168e7b9f6ea189eeb7d5d9fbbb99d26 + languageName: node + linkType: hard + +"motion-utils@npm:^12.39.0": + version: 12.39.0 + resolution: "motion-utils@npm:12.39.0" + checksum: 10c0/6d7a2a2cc0797b72410a666a9cc1c201c8e39bf9669670464e433fe1e72af5f0217154c869867b34fbadf3664cf222c0d022bbc4eed7927f201ae971918e7440 + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"nanoid@npm:^3.3.18": + version: 3.3.19 + resolution: "nanoid@npm:3.3.19" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/ce7afa6258bfaa00bdc8de291cfe9913280eaa403b7d9cff3db4e4200c1913e7163577df74897958da144db473a96c67f74f57db0f542fbfe409df677bae3255 + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 13.1.0 + resolution: "node-gyp@npm:13.1.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + nopt: "npm:^10.0.0" + proc-log: "npm:^7.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.7" + tinyglobby: "npm:^0.2.12" + undici: "npm:^8.4.1" + which: "npm:^7.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/c6693e52e0e111b21f5cd8ff303b41b51f40338adcc79ac5c6a02399eba7039d50dc816dcafcc485870291a641f7cb75db8887a9bdd0de537fe635a3a81cba5b + languageName: node + linkType: hard + +"node-releases@npm:^2.0.57": + version: 2.0.57 + resolution: "node-releases@npm:2.0.57" + checksum: 10c0/fe5b68fba1fef524243b54828a118f13a4a40db08bb42c0b92680c3ca5c246f4ae6f139515071d46b9a3a9195945f5ac78064f5b7674c4dcc3dd6bfe933ff9e8 + languageName: node + linkType: hard + +"nopt@npm:^10.0.0": + version: 10.0.1 + resolution: "nopt@npm:10.0.1" + dependencies: + abbrev: "npm:^5.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a + languageName: node + linkType: hard + +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^4.0.4, picomatch@npm:^4.0.7": + version: 4.0.7 + resolution: "picomatch@npm:4.0.7" + checksum: 10c0/beb6ae02c43ae44e84883b90830196d9046b1726ead292adcf7f57945e0bb0d992d68563d87e03b484b6f3c9a5c6defda7523477f047d7f0e663f126cc01787f + languageName: node + linkType: hard + +"postcss@npm:^8.5.28": + version: 8.5.28 + resolution: "postcss@npm:8.5.28" + dependencies: + nanoid: "npm:^3.3.18" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/9fe44215a6628d89c8a75184b92f5b2f77e16f9e5b13b39b9009bb7e8e6eccddf82c8854bae922db1f17ace6ef3445073fe38abff513caeb03e5285b1b55620a + languageName: node + linkType: hard + +"prettier@npm:^3.9.6": + version: 3.9.9 + resolution: "prettier@npm:3.9.9" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/d50ea2537b2ff7dbd5088a214f4d936484e55c665cea2185b477a55b0770be55f3b156a71f63a9a639def6b334667bb327139dc85f2a1041c0a34752cdc74647 + languageName: node + linkType: hard + +"proc-log@npm:^7.0.0": + version: 7.0.0 + resolution: "proc-log@npm:7.0.0" + checksum: 10c0/b89c2d862604f35fec795477b0c7e376feab3ba0d4f4d291c4e959567442697cf451ac557d0623c1cc38af45a78128b983410f397a10c5d3a67f76c33de4754b + languageName: node + linkType: hard + +"radix-ui@npm:^1.6.7": + version: 1.6.7 + resolution: "radix-ui@npm:1.6.7" + dependencies: + "@radix-ui/primitive": "npm:1.1.7" + "@radix-ui/react-accessible-icon": "npm:1.1.15" + "@radix-ui/react-accordion": "npm:1.2.20" + "@radix-ui/react-alert-dialog": "npm:1.1.23" + "@radix-ui/react-arrow": "npm:1.1.15" + "@radix-ui/react-aspect-ratio": "npm:1.1.15" + "@radix-ui/react-avatar": "npm:1.2.6" + "@radix-ui/react-checkbox": "npm:1.3.11" + "@radix-ui/react-collapsible": "npm:1.1.20" + "@radix-ui/react-collection": "npm:1.1.15" + "@radix-ui/react-compose-refs": "npm:1.1.5" + "@radix-ui/react-context": "npm:1.2.2" + "@radix-ui/react-context-menu": "npm:2.3.7" + "@radix-ui/react-dialog": "npm:1.1.23" + "@radix-ui/react-direction": "npm:1.1.4" + "@radix-ui/react-dismissable-layer": "npm:1.1.19" + "@radix-ui/react-dropdown-menu": "npm:2.1.24" + "@radix-ui/react-focus-guards": "npm:1.1.6" + "@radix-ui/react-focus-scope": "npm:1.1.16" + "@radix-ui/react-form": "npm:0.1.16" + "@radix-ui/react-hover-card": "npm:1.1.23" + "@radix-ui/react-label": "npm:2.1.15" + "@radix-ui/react-menu": "npm:2.1.24" + "@radix-ui/react-menubar": "npm:1.1.24" + "@radix-ui/react-navigation-menu": "npm:1.2.22" + "@radix-ui/react-one-time-password-field": "npm:0.1.16" + "@radix-ui/react-password-toggle-field": "npm:0.1.11" + "@radix-ui/react-popover": "npm:1.1.23" + "@radix-ui/react-popper": "npm:1.3.7" + "@radix-ui/react-portal": "npm:1.1.17" + "@radix-ui/react-presence": "npm:1.1.10" + "@radix-ui/react-primitive": "npm:2.1.10" + "@radix-ui/react-progress": "npm:1.1.16" + "@radix-ui/react-radio-group": "npm:1.4.7" + "@radix-ui/react-roving-focus": "npm:1.1.19" + "@radix-ui/react-scroll-area": "npm:1.2.18" + "@radix-ui/react-select": "npm:2.3.7" + "@radix-ui/react-separator": "npm:1.1.15" + "@radix-ui/react-slider": "npm:1.4.7" + "@radix-ui/react-slot": "npm:1.3.3" + "@radix-ui/react-switch": "npm:1.3.7" + "@radix-ui/react-tabs": "npm:1.1.21" + "@radix-ui/react-toast": "npm:1.2.23" + "@radix-ui/react-toggle": "npm:1.1.18" + "@radix-ui/react-toggle-group": "npm:1.1.19" + "@radix-ui/react-toolbar": "npm:1.1.19" + "@radix-ui/react-tooltip": "npm:1.2.16" + "@radix-ui/react-use-callback-ref": "npm:1.1.4" + "@radix-ui/react-use-controllable-state": "npm:1.2.6" + "@radix-ui/react-use-effect-event": "npm:0.0.5" + "@radix-ui/react-use-escape-keydown": "npm:1.1.5" + "@radix-ui/react-use-is-hydrated": "npm:0.1.3" + "@radix-ui/react-use-layout-effect": "npm:1.1.4" + "@radix-ui/react-use-size": "npm:1.1.4" + "@radix-ui/react-visually-hidden": "npm:1.2.11" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/403b99f811ac13e910400ebef62bf4838879e8df3979cf5a471b4d22f4e7e291d2e4a52a35c08d861dcc58c6ac16da85c3c89f92503a47665d5555609774af4a + languageName: node + linkType: hard + +"react-dom@npm:^19.3.0": + version: 19.3.0 + resolution: "react-dom@npm:19.3.0" + dependencies: + scheduler: "npm:^0.28.0" + peerDependencies: + react: ^19.3.0 + checksum: 10c0/c513044b724dd6bfe9500a9682d60973e7f8a48cffba00b6b16d3e910e27dcc459bf71bf0a5c33d7d62a3843ca30de1bd1e2098058aa4ed71f618a24b6061da5 + languageName: node + linkType: hard + +"react-remove-scroll-bar@npm:^2.3.7": + version: 2.3.8 + resolution: "react-remove-scroll-bar@npm:2.3.8" + dependencies: + react-style-singleton: "npm:^2.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/9a0675c66cbb52c325bdbfaed80987a829c4504cefd8ff2dd3b6b3afc9a1500b8ec57b212e92c1fb654396d07bbe18830a8146fe77677d2a29ce40b5e1f78654 + languageName: node + linkType: hard + +"react-remove-scroll@npm:^2.7.2": + version: 2.7.2 + resolution: "react-remove-scroll@npm:2.7.2" + dependencies: + react-remove-scroll-bar: "npm:^2.3.7" + react-style-singleton: "npm:^2.2.3" + tslib: "npm:^2.1.0" + use-callback-ref: "npm:^1.3.3" + use-sidecar: "npm:^1.1.3" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/b5f3315bead75e72853f492c0b51ba8fb4fa09a4534d7fc42d6fcd59ca3e047cf213279ffc1e35b337e314ef5a04cb2b12544c85e0078802271731c27c09e5aa + languageName: node + linkType: hard + +"react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": + version: 2.2.3 + resolution: "react-style-singleton@npm:2.2.3" + dependencies: + get-nonce: "npm:^1.0.0" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/841938ff16d16a6b76895f4cb2e1fea957e5fe3b30febbf03a54892dae1c9153f2383e231dea0b3ba41192ad2f2849448fa859caccd288943bce32639e971bee + languageName: node + linkType: hard + +"react@npm:^19.3.0": + version: 19.3.0 + resolution: "react@npm:19.3.0" + checksum: 10c0/dd3099c3c43a7122bc01223bb26b0f8d5ba13cca569af6fb94a005fb54fa73223c9cb203bb226d40cfd49a82cbeef2e36ce84062a3a6d5b9fe8f4437694c7b52 + languageName: node + linkType: hard + +"readdirp@npm:^5.0.0": + version: 5.1.1 + resolution: "readdirp@npm:5.1.1" + checksum: 10c0/5e99755684b9104761e48801828f4d2614efa33b1698629929c5985184dc63a7f8233c827832c40dc7be16b00cc871d303cf9de396d2fb74b9465ee2aa2424a1 + languageName: node + linkType: hard + +"rolldown@npm:~1.2.11": + version: 1.2.12 + resolution: "rolldown@npm:1.2.12" + dependencies: + "@oxc-project/types": "npm:=0.152.0" + "@rolldown/binding-android-arm-eabi": "npm:1.2.12" + "@rolldown/binding-android-arm64": "npm:1.2.12" + "@rolldown/binding-darwin-arm64": "npm:1.2.12" + "@rolldown/binding-darwin-x64": "npm:1.2.12" + "@rolldown/binding-freebsd-x64": "npm:1.2.12" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.12" + "@rolldown/binding-linux-arm64-gnu": "npm:1.2.12" + "@rolldown/binding-linux-arm64-musl": "npm:1.2.12" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.12" + "@rolldown/binding-linux-s390x-gnu": "npm:1.2.12" + "@rolldown/binding-linux-x64-gnu": "npm:1.2.12" + "@rolldown/binding-linux-x64-musl": "npm:1.2.12" + "@rolldown/binding-openharmony-arm64": "npm:1.2.12" + "@rolldown/binding-win32-arm64-msvc": "npm:1.2.12" + "@rolldown/binding-win32-x64-msvc": "npm:1.2.12" + "@rolldown/pluginutils": "npm:^1.0.0" + dependenciesMeta: + "@rolldown/binding-android-arm-eabi": + optional: true + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: ./bin/cli.mjs + checksum: 10c0/5e01b955608d9ee601359caa228155c99b2ac4e9e5ef8cbff1962654ab32e5f406ae6872576aa69104b94705bb3743bdb042038c120d5197c1edb18588272aac + languageName: node + linkType: hard + +"scheduler@npm:^0.28.0": + version: 0.28.0 + resolution: "scheduler@npm:0.28.0" + checksum: 10c0/783c8e5ce89d41273a02ed6dc0e3d24bd60e05c9e8f7e3adfc6c82e7c69935500d90e343f1ee26d592fc7504e8a2532573c6368def3e93bfe812391fa53efaf4 + languageName: node + linkType: hard + +"semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" + bin: + semver: bin/semver.js + checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d + languageName: node + linkType: hard + +"semver@npm:^7.3.5": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c + languageName: node + linkType: hard + +"seroval-plugins@npm:^1.6.7": + version: 1.6.8 + resolution: "seroval-plugins@npm:1.6.8" + peerDependencies: + seroval: ^1.0 + checksum: 10c0/1510c7b977bc4e3b1a1dd2841e600ba555ac02e74a0ea0378a782dc821fb11b3578b288a4aed7a5ead9998d0a6a3f8f5ee94338a949902cbb86e11fcf0ec8949 + languageName: node + linkType: hard + +"seroval@npm:^1.6.7": + version: 1.6.8 + resolution: "seroval@npm:1.6.8" + checksum: 10c0/c65f4ca1bd712d89c70c86df41bedc8270ee33cc8365c867761a9fe6310ab3b8c0fc43a48f6c1cea3cf9b5cc362714e7325e5f9275407303e4c51dfcebeb98b2 + languageName: node + linkType: hard + +"source-map-js@npm:^1.2.1": + version: 1.2.2 + resolution: "source-map-js@npm:1.2.2" + checksum: 10c0/d5d6dec62266834f83dd4f8a2606a196fb1a006ca5d53b0ef80ee513b7eba2f4e2ae241f4bc129ae7e9b80dea24f6e3d92b446b7e6e96be7653cab8888168a94 + languageName: node + linkType: hard + +"tailwind-merge@npm:^3.7.0": + version: 3.7.0 + resolution: "tailwind-merge@npm:3.7.0" + checksum: 10c0/1b1439fe30b6ee63fc3abc1c985bf9beb8529eb2db21cc321bd8c7cf73f36cd4ec80846ea10af8ae09175810f385336c22a6ce355a0b5cce69432d49672f90c1 + languageName: node + linkType: hard + +"tailwindcss@npm:4.3.3, tailwindcss@npm:^4.3.3": + version: 4.3.3 + resolution: "tailwindcss@npm:4.3.3" + checksum: 10c0/7e9cd7553cd890ffa5350efb0ca8971ba5435257d0f98bf0714994ac8f1fee5c7cdf9a9da47fe83e8ac995d728410837ac49d3ef5ea66b5afe1db312086ffae2 + languageName: node + linkType: hard + +"tapable@npm:^2.3.3": + version: 2.3.3 + resolution: "tapable@npm:2.3.3" + checksum: 10c0/47992e861053f861154e92fb4a98ac4ab47b6463717e60792dd1e8c755da0c4964cd8bb68c308a9066d6da89000b6310457b4d5d985c30de4ccc29066068cc17 + languageName: node + linkType: hard + +"tar@npm:^7.5.7": + version: 7.5.22 + resolution: "tar@npm:7.5.22" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/1311f6be85a8157ac4c9147bae43e13923d2a1aae15e4aa1bd5239e4e03d2cf53cfe103dde7f35832fbb4c938b042856bc8e9a0afd29abd05e2d1608788c4fea + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.17": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + +"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + +"typescript@npm:^5.7.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.7.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard + +"undici-types@npm:~8.9.0": + version: 8.9.0 + resolution: "undici-types@npm:8.9.0" + checksum: 10c0/51c712c20c087368f62c6fc0f2bc49fa76bfe5dec28ff01584bb677ff29b0622569783c02d43e151143808c48c0503f804093bd2c491b10f2a830138034cb451 + languageName: node + linkType: hard + +"undici@npm:^8.4.1": + version: 8.11.2 + resolution: "undici@npm:8.11.2" + checksum: 10c0/106f6991178fb7562fd90cdec7993c5f807a9428682306091f0c4b27fc474ec291f868e29972c6252988bf061ad193df943b3b99e775be157a28d92bbb95699d + languageName: node + linkType: hard + +"unplugin@npm:^3.3.0": + version: 3.4.0 + resolution: "unplugin@npm:3.4.0" + dependencies: + "@jridgewell/remapping": "npm:^2.3.5" + picomatch: "npm:^4.0.7" + webpack-virtual-modules: "npm:^0.6.2" + peerDependencies: + "@farmfe/core": "*" + "@rsbuild/core": "*" + "@rspack/core": "*" + bun-types-no-globals: "*" + esbuild: "*" + rolldown: "*" + rollup: "*" + unloader: "*" + vite: "*" + webpack: "*" + peerDependenciesMeta: + "@farmfe/core": + optional: true + "@rsbuild/core": + optional: true + "@rspack/core": + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + checksum: 10c0/d4674eec8c4093f42aa62736d68e85b4a366fcc45b8e910179f6f39f890d7b69c84179a769d3a6c23d41e658f89a2300f66474c126fee3f212330afedf48079a + languageName: node + linkType: hard + +"update-browserslist-db@npm:^1.3.3": + version: 1.3.3 + resolution: "update-browserslist-db@npm:1.3.3" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10c0/07bab166c18f47ba51f3b755e42f109ef5069d0d7c7224887decbd523d5ccd404f63661ebd5a740099972ed47f41f0e28c04df8be31d76716a346f948ce2aa61 + languageName: node + linkType: hard + +"use-callback-ref@npm:^1.3.3": + version: 1.3.3 + resolution: "use-callback-ref@npm:1.3.3" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/f887488c6e6075cdad4962979da1714b217bcb1ee009a9e57ce9a844bcfc4c3a99e93983dfc2e5af9e0913824d24e730090ff255e902c516dcb58d2d3837e01c + languageName: node + linkType: hard + +"use-sidecar@npm:^1.1.3": + version: 1.1.3 + resolution: "use-sidecar@npm:1.1.3" + dependencies: + detect-node-es: "npm:^1.1.0" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/161599bf921cfaa41c85d2b01c871975ee99260f3e874c2d41c05890d41170297bdcf314bc5185e7a700de2034ac5b888e3efc8e9f35724f4918f53538d717c9 + languageName: node + linkType: hard + +"use-sync-external-store@npm:^1.6.0": + version: 1.7.0 + resolution: "use-sync-external-store@npm:1.7.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/6bae7cb778ce611131ec830c4e469fbc2872acefc25d2e4cf1c28b8628690689770742a9f2f0642c95134f9c77041fee60f0352f81ca43f96643b0ed01774726 + languageName: node + linkType: hard + +"vite-shopify-storefront@workspace:.": + version: 0.0.0-use.local + resolution: "vite-shopify-storefront@workspace:." + dependencies: + "@fontsource-variable/geist": "npm:^5.3.0" + "@fontsource-variable/geist-mono": "npm:^5.3.0" + "@hono/node-server": "npm:^2.1.3" + "@hono/vite-dev-server": "npm:^0.26.1" + "@remixicon/react": "npm:^4.9.0" + "@shopify/hydrogen": "npm:0.0.0-preview-116d5d7-20260730141607" + "@tailwindcss/vite": "npm:^4.3.3" + "@tanstack/react-query": "npm:^5.104.1" + "@tanstack/react-router": "npm:^1.170.41" + "@tanstack/router-plugin": "npm:^1.168.42" + "@types/node": "npm:^26.6.4" + "@types/react": "npm:^19.3.0" + "@types/react-dom": "npm:^19.3.0" + "@vitejs/plugin-react": "npm:^6.1.1" + class-variance-authority: "npm:^0.7.1" + clsx: "npm:^2.1.1" + cmdk: "npm:^1.1.1" + framer-motion: "npm:^12.42.2" + hono: "npm:^4.13.12" + lucide-react: "npm:^0.562.0" + radix-ui: "npm:^1.6.7" + react: "npm:^19.3.0" + react-dom: "npm:^19.3.0" + tailwind-merge: "npm:^3.7.0" + tailwindcss: "npm:^4.3.3" + typescript: "npm:^5.7.3" + vite: "npm:^8.3.2" + zustand: "npm:^5.0.15" + languageName: unknown + linkType: soft + +"vite@npm:^8.3.2": + version: 8.3.2 + resolution: "vite@npm:8.3.2" + dependencies: + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.33.0" + picomatch: "npm:^4.0.7" + postcss: "npm:^8.5.28" + rolldown: "npm:~1.2.11" + tinyglobby: "npm:^0.2.17" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.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 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/bc2dc5b9077624686a0e4ddfdbfca9d35bf97a32c53966bf1cdd7f355a8f2c488c0422d1b02328a959cfd06b490f98c7e24938f8a251a1039adf2219f2da552e + languageName: node + linkType: hard + +"webpack-virtual-modules@npm:^0.6.2": + version: 0.6.2 + resolution: "webpack-virtual-modules@npm:0.6.2" + checksum: 10c0/5ffbddf0e84bf1562ff86cf6fcf039c74edf09d78358a6904a09bbd4484e8bb6812dc385fe14330b715031892dcd8423f7a88278b57c9f5002c84c2860179add + languageName: node + linkType: hard + +"which@npm:^7.0.0": + version: 7.0.0 + resolution: "which@npm:7.0.0" + dependencies: + isexe: "npm:^4.0.0" + bin: + node-which: bin/which.js + checksum: 10c0/ca0b54f198f78bbc4b7c02e34bda8d335cb352e0adb4cbca1c37b1a957af3a879a82c4c27ca6525bc942f548d8b64f816ef6528360af9f3de55ffb9b979b620d + languageName: node + linkType: hard + +"yallist@npm:^3.0.2": + version: 3.1.1 + resolution: "yallist@npm:3.1.1" + checksum: 10c0/c66a5c46bc89af1625476f7f0f2ec3653c1a1791d2f9407cfb4c2ba812a1e1c9941416d71ba9719876530e3340a99925f697142989371b72d93b9ee628afd8c1 + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard + +"zod@npm:^4.5.4": + version: 4.6.5 + resolution: "zod@npm:4.6.5" + checksum: 10c0/32eb6c5e98becbe045675b2141c3611451c16ba567f9779231d79af41c4d651ca1c22f154aef7e6c6e77b929f23e43f1154ca05b82e119e922353725d6776af5 + languageName: node + linkType: hard + +"zustand@npm:^5.0.15": + version: 5.0.15 + resolution: "zustand@npm:5.0.15" + peerDependencies: + "@types/react": ">=18.0.0" + immer: ">=9.0.6" + react: ">=18.0.0" + use-sync-external-store: ">=1.2.0" + peerDependenciesMeta: + "@types/react": + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + checksum: 10c0/ed3a883950a8c22d98e8c72cef103af18043ce66b45faf218ad20ccc8ee29716aacfc6b0a3e783b2152ea38b7663f3982bb6bf2b13ce3df692b05276446afa04 + languageName: node + linkType: hard