diff --git a/.gitignore b/.gitignore index 0f36f54fa..938c6fae1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ drizzle/migrations .DS_Store .cache .env +.env.* .vercel .output .vinxi diff --git a/docs/performance-plan-home-library-docs.md b/docs/performance-plan-home-library-docs.md new file mode 100644 index 000000000..32b505bd2 --- /dev/null +++ b/docs/performance-plan-home-library-docs.md @@ -0,0 +1,216 @@ +# Homepage, Library Landing, and Docs Performance Plan + +## Goal + +Make the homepage, library landing pages, and docs materially faster, lighter, and cheaper to render without regressing content quality or navigation UX. + +## Current Baseline + +- Homepage route chunk: `dist/client/assets/index-Bq0A5jmY.js` at `564.79 kB / 172.14 kB gzip` +- Shared shell chunk: `dist/client/assets/app-shell-BikUtTEO.js` at `349.55 kB / 110.08 kB gzip` +- Search modal chunk: `dist/client/assets/SearchModal-Bl-tUxqr.js` at `195.54 kB / 54.27 kB gzip` +- Docs shell chunk: `dist/client/assets/DocsLayout-Bga1-HA9.js` at `17.61 kB / 6.05 kB gzip` +- Markdown chrome chunk: `dist/client/assets/MarkdownContent-ia2V1dk8.js` at `19.37 kB / 6.39 kB gzip` +- Global CSS: `dist/client/assets/app-CBMELhsb.css` at `319.24 kB / 40.48 kB gzip` + +## Main Problems + +- Homepage ships too much in one route chunk. +- Library landing pages pay docs-shell and docs-config cost before the user asks for docs. +- Docs still do too much work per request even with GitHub content caching. +- Hidden docs UI still mounts and runs effects/queries. +- Anonymous docs users still trigger auth-related client queries for framework preference. +- Some "lazy" controls are effectively eager. + +## Success Targets + +- Cut the homepage route chunk hard enough that it is no longer one of the top client payloads. +- Remove docs-config and docs-layout from the critical path for landing pages. +- Turn docs page rendering into mostly cached work. +- Avoid client queries on first paint for content that can be rendered server-side. +- Reduce hidden-work JS on docs mobile and desktop layouts. + +## Workstreams + +### 1. Homepage route diet + +Targets: + +- `src/routes/index.tsx` +- `src/components/OpenSourceStats.tsx` +- `src/components/ShowcaseSection.tsx` +- `src/components/PartnersGrid.tsx` +- `src/components/MaintainerCard.tsx` + +Changes: + +- Break below-the-fold homepage sections into viewport-triggered lazy boundaries. +- Move recent posts off client `useQuery` and into route loader or server-rendered data. +- Stop client-fetching OSS stats on initial paint. Render a server snapshot first. +- Keep `DeferredApplicationStarter` deferred by visibility or interaction, not just idle timeout. +- Avoid eagerly importing large static datasets into the first route chunk where possible. +- Stop rendering both light and dark hero image variants eagerly. + +Expected win: + +- Lower homepage JS, lower hydration cost, lower first-load network. + +### 2. Dedicated library landing shell + +Targets: + +- `src/routes/-library-landing.tsx` +- `src/components/DocsLayout.tsx` +- landing components under `src/components/landing/` + +Changes: + +- Introduce a dedicated `LibraryLandingLayout`. +- Remove `DocsLayout` from landing pages. +- Stop fetching docs `config.json` in the landing-page critical path unless a landing section actually needs it. +- Keep framework/version/docs navigation lightweight on landing pages and hand off to docs only when needed. + +Expected win: + +- Better landing-page TTFB, less landing-page JS, less docs chrome on non-docs surfaces. + +### 3. Docs render caching + +Targets: + +- `src/utils/docs.functions.ts` +- `src/utils/github-content-cache.server.ts` +- `src/utils/markdown/renderRsc.tsx` +- `src/utils/markdown/processor.rsc.tsx` +- `src/components/markdown/renderCodeBlock.server.tsx` + +Changes: + +- Cache rendered docs artifacts, not just raw GitHub files. +- Persist `title`, `description`, `headings`, and rendered output keyed by repo, ref, docs root, and path. +- Reuse existing docs artifact cache infra instead of adding a second caching path. +- Make docs requests mostly cache hits unless the source changed. + +Expected win: + +- Better docs TTFB, less server CPU, fewer repeated markdown and Shiki passes. + +### 4. Docs layout mount discipline + +Targets: + +- `src/components/DocsLayout.tsx` +- `src/components/RightRail.tsx` +- `src/components/RecentPostsWidget.tsx` + +Changes: + +- Do not mount mobile docs menu on desktop. +- Do not mount desktop docs menu on mobile. +- Do not mount right rail when hidden by breakpoint. +- Gate animated partner strip work by actual viewport and reduced-motion preference. +- Ensure hidden rails do not issue queries or observers. + +Expected win: + +- Lower docs runtime cost, especially on mobile. + +### 5. Remove anonymous auth work from docs and landing + +Targets: + +- `src/components/FrameworkSelect.tsx` +- `src/hooks/useCurrentUser.ts` +- `src/components/SearchModal.tsx` +- `src/components/NavbarAuthControls.tsx` + +Changes: + +- Make framework preference local-first for anonymous users. +- Only sync framework preference to server when user state is already known. +- Avoid triggering `getCurrentUser` on docs and landing pages just to resolve a preference. +- Audit other shell components for accidental auth fetches during anonymous browsing. + +Expected win: + +- Fewer unnecessary client requests, cleaner anonymous docs navigation. + +### 6. Shared shell cleanup + +Targets: + +- `src/routes/__root.tsx` +- `src/router.tsx` +- `src/components/Navbar.tsx` +- `src/components/markdown/MarkdownContent.tsx` + +Changes: + +- Verify why some intended dynamic imports are not splitting effectively. +- Trim eager shell work around Sentry boot where possible. +- Fix `MarkdownContent` so `CopyPageDropdown` only loads on real interaction. +- Review navbar asset duplication and avoid eager light/dark image duplication where possible. + +Expected win: + +- Smaller app shell, less global cost paid by every route. + +## Suggested Implementation Order + +1. Fix obviously accidental eager work. +2. Make docs layout mount only what is visible. +3. Remove anonymous auth fetches from docs and landing flows. +4. Add dedicated library landing shell and remove docs-config from landing critical path. +5. Move homepage content and stats to server-first data flows and split below-the-fold sections. +6. Add rendered docs artifact caching. +7. Rebuild and compare chunks, request timings, and interaction cost. + +## PR Breakdown + +### PR 1 + +- Fix `MarkdownContent` eager copy-dropdown load +- Stop hidden docs rails and menus from mounting +- Gate mobile partner strip animation correctly + +### PR 2 + +- Remove anonymous auth fetches from framework selection and related docs shell code + +### PR 3 + +- Add `LibraryLandingLayout` +- Remove `DocsLayout` and docs config dependency from landing critical path + +### PR 4 + +- Split homepage below the fold +- Server-render recent posts and stats +- Tighten app-starter deferral + +### PR 5 + +- Cache rendered docs artifacts +- Measure docs TTFB and server CPU improvement + +### PR 6 + +- Shared shell follow-up: Sentry boot, navbar assets, remaining bundle outliers + +## Verification + +- Run `pnpm build` after each major phase. +- Track homepage, a representative library landing page, and a representative docs page. +- Compare: +- route chunk size +- app shell size +- docs TTFB +- number of client requests on first load +- whether anonymous docs visits trigger user/auth requests +- smoke-check desktop and mobile docs navigation + +## Notes + +- `LazyLandingCommunitySection` and `LazySponsorSection` already use the right pattern. Reuse that pattern more aggressively. +- `StackBlitzEmbed` is already `loading="lazy"`, but a poster-plus-click model may still be worth it for landing pages. +- Do not spend time micro-optimizing heading observers or tiny docs chunks before fixing the homepage and landing-page architecture. diff --git a/docs/proposals/npm-watchlist-registry-draft.md b/docs/proposals/npm-watchlist-registry-draft.md new file mode 100644 index 000000000..1e6a4f612 --- /dev/null +++ b/docs/proposals/npm-watchlist-registry-draft.md @@ -0,0 +1,694 @@ +# Draft: NPM Watchlist Registry + +## Purpose + +Define a source-of-truth registry for tracked npm entities, rollups, and curated watchlists. + +This registry should replace the idea that `popular comparisons` are the canonical list. Popular comparisons can still exist, but they should derive from this registry. + +Rollups should be the reusable grouping abstraction. + +Watchlists should remain the primary user-facing saved and subscribed surface. Categories are metadata on entities, not the source of truth for ranking surfaces. + +## Design Goals + +- Model logical libraries, not just raw package names. +- Support legacy package rollups. +- Support reusable rollup definitions for ecosystems and grouped chart views. +- Keep curation explicit and reviewable in code. +- Make it easy to derive chart `packageGroups` for the existing UI. +- Leave room for future time-aware lineage rules without requiring them in v1. + +## Suggested File + +- `src/utils/npm-watchlists.ts` + +## Proposed Types + +```ts +export type NpmTrackedEntity = { + id: string + label: string + shortLabel?: string + description?: string + categories: Array< + | 'tanstack' + | 'data-fetching' + | 'routing' + | 'state' + | 'table' + | 'form' + | 'virtualization' + | 'testing' + | 'styling' + | 'build' + | 'validation' + | 'docs' + | 'framework' + | 'animation' + | 'tooling' + | 'database' + > + color?: string + packages: Array<{ + name: string + from?: string + to?: string + }> + lineageStrategy?: 'sum-all' | 'time-bounded' + benchmarkEligible?: boolean + popularComparisonEligible?: boolean + hidden?: boolean +} + +export type NpmWatchlist = { + id: string + title: string + description?: string + kind: 'curated-category' | 'curated-benchmark' | 'curated-tanstack' + entityIds?: string[] + rollupIds?: string[] + featured?: boolean + public?: boolean + popularComparison?: boolean + benchmark?: boolean +} + +export type NpmRollup = { + id: string + title: string + description?: string + kind: 'ecosystem' | 'category' | 'benchmark' | 'editorial' + entityIds: string[] + membershipMode?: 'exclusive' | 'overlap' + color?: string + public?: boolean +} +``` + +## V1 Simplification + +Even though the entity type allows `from` and `to`, v1 can treat almost all tracked entities as: + +- `lineageStrategy: 'sum-all'` +- all packages included for the full period + +That matches the current stats architecture and keeps the first implementation simple. + +## Derivations + +The registry should support these outputs: + +1. `getWatchlist(id)` +2. `getTrackedEntity(id)` +3. `getRollup(id)` +4. `getFeaturedWatchlists()` +5. `toPackageGroups(watchlist)` for existing chart routes +6. `toPopularComparisons()` to replace hand-maintained duplication +7. `groupEntitiesByRollup(entityIds, rollupIds)` for charting and digest summarization + +## Curation Rules + +### Tracked entities + +- One entity should represent one logical product or library line. +- One entity can belong to multiple categories. +- Legacy package names should be included when they clearly map to the same product lineage. +- Do not merge adjacent but meaningfully different products just because users might compare them. + +### Watchlists + +- A curated category watchlist should feel category-coherent. +- A benchmark watchlist should be broad, stable, and slow-changing. +- A featured watchlist should be editorially maintained and reviewed regularly. + +### Rollups + +- A rollup is a reusable editorial grouping of entities. +- Rollups are explicit, not inferred from npm scopes or package naming. +- Some rollups can overlap. +- Some rollups should be exclusive where double counting would distort rankings. +- Rollups should be stable enough to support long-term historical views. + +### Review cadence + +- Featured watchlists: monthly review +- Benchmark watchlists: quarterly review +- Ecosystem rollups: quarterly review +- Entity lineage changes: only when we have high confidence + +## Starter Tracked Entities + +This is not exhaustive. It is the first pass for v1. + +```ts +export const trackedEntities: NpmTrackedEntity[] = [ + { + id: 'tanstack-query', + label: 'TanStack Query', + categories: ['tanstack', 'data-fetching'], + color: '#FF4500', + packages: [{ name: '@tanstack/react-query' }, { name: 'react-query' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'swr', + label: 'SWR', + categories: ['data-fetching'], + color: '#ec4899', + packages: [{ name: 'swr' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'apollo-client', + label: 'Apollo Client', + categories: ['data-fetching'], + color: '#6B46C1', + packages: [{ name: '@apollo/client' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'trpc-client', + label: 'tRPC Client', + categories: ['data-fetching'], + color: '#2596BE', + packages: [{ name: '@trpc/client' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'tanstack-router', + label: 'TanStack Router', + categories: ['tanstack', 'routing'], + color: '#32CD32', + packages: [{ name: '@tanstack/react-router' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'react-router', + label: 'React Router', + categories: ['routing'], + color: '#FF0000', + packages: [{ name: 'react-router' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'wouter', + label: 'Wouter', + categories: ['routing'], + color: '#8b5cf6', + packages: [{ name: 'wouter' }], + popularComparisonEligible: true, + }, + { + id: 'tanstack-table', + label: 'TanStack Table', + categories: ['tanstack', 'table'], + color: '#FF7043', + packages: [{ name: '@tanstack/react-table' }, { name: 'react-table' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'ag-grid', + label: 'AG Grid', + categories: ['table'], + color: '#29B6F6', + packages: [{ name: 'ag-grid-community' }, { name: 'ag-grid-enterprise' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'mui-data-grid', + label: 'MUI Data Grid', + categories: ['table'], + color: '#1976D2', + packages: [{ name: '@mui/x-data-grid' }, { name: 'mui-datatables' }], + popularComparisonEligible: true, + }, + { + id: 'tanstack-form', + label: 'TanStack Form', + categories: ['tanstack', 'form'], + color: '#FFD700', + packages: [ + { name: '@tanstack/form-core' }, + { name: '@tanstack/react-form' }, + ], + popularComparisonEligible: true, + }, + { + id: 'react-hook-form', + label: 'React Hook Form', + categories: ['form'], + color: '#EC5990', + packages: [{ name: 'react-hook-form' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'conform', + label: 'Conform', + categories: ['form'], + color: '#FF5733', + packages: [{ name: '@conform-to/dom' }], + popularComparisonEligible: true, + }, + { + id: 'tanstack-virtual', + label: 'TanStack Virtual', + categories: ['tanstack', 'virtualization'], + color: '#8B5CF6', + packages: [{ name: '@tanstack/react-virtual' }, { name: 'react-virtual' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'react-window', + label: 'react-window', + categories: ['virtualization'], + color: '#4ECDC4', + packages: [{ name: 'react-window' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'react-virtualized', + label: 'react-virtualized', + categories: ['virtualization'], + color: '#FF6B6B', + packages: [{ name: 'react-virtualized' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'redux', + label: 'Redux', + categories: ['state'], + color: '#764ABC', + packages: [{ name: 'redux' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'zustand', + label: 'Zustand', + categories: ['state'], + color: '#764ABC', + packages: [{ name: 'zustand' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'jotai', + label: 'Jotai', + categories: ['state'], + color: '#6366f1', + packages: [{ name: 'jotai' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'valtio', + label: 'Valtio', + categories: ['state'], + color: '#FF6B6B', + packages: [{ name: 'valtio' }], + popularComparisonEligible: true, + }, + { + id: 'vite', + label: 'Vite', + categories: ['build', 'tooling'], + color: '#008000', + packages: [{ name: 'vite' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'webpack', + label: 'Webpack', + categories: ['build', 'tooling'], + color: '#8DD6F9', + packages: [{ name: 'webpack' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'rollup', + label: 'Rollup', + categories: ['build', 'tooling'], + color: '#e80A3F', + packages: [{ name: 'rollup' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'esbuild', + label: 'esbuild', + categories: ['build', 'tooling'], + color: '#FFCF00', + packages: [{ name: 'esbuild' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'rspack', + label: 'Rspack', + categories: ['build', 'tooling'], + color: '#8DD6F9', + packages: [{ name: '@rspack/core' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'zod', + label: 'Zod', + categories: ['validation'], + color: '#ef4444', + packages: [{ name: 'zod' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'valibot', + label: 'Valibot', + categories: ['validation'], + color: '#f97316', + packages: [{ name: 'valibot' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'yup', + label: 'Yup', + categories: ['validation'], + color: '#06b6d4', + packages: [{ name: 'yup' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'react', + label: 'React', + categories: ['framework'], + color: '#61DAFB', + packages: [{ name: 'react' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'vue', + label: 'Vue', + categories: ['framework'], + color: '#41B883', + packages: [{ name: 'vue' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'angular-core', + label: 'Angular', + categories: ['framework'], + color: '#DD0031', + packages: [{ name: '@angular/core' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'svelte', + label: 'Svelte', + categories: ['framework'], + color: '#FF3E00', + packages: [{ name: 'svelte' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, + { + id: 'solid-js', + label: 'Solid', + categories: ['framework'], + color: '#2C4F7C', + packages: [{ name: 'solid-js' }], + benchmarkEligible: true, + popularComparisonEligible: true, + }, +] +``` + +## Starter Curated Watchlists + +```ts +export const watchlists: NpmWatchlist[] = [ + { + id: 'data-fetching', + title: 'Data Fetching', + kind: 'curated-category', + entityIds: ['tanstack-query', 'swr', 'apollo-client', 'trpc-client'], + featured: true, + public: true, + popularComparison: true, + }, + { + id: 'routing-react', + title: 'Routing (React)', + kind: 'curated-category', + entityIds: ['react-router', 'tanstack-router', 'wouter'], + featured: true, + public: true, + popularComparison: true, + }, + { + id: 'tables-data-grids', + title: 'Tables and Data Grids', + kind: 'curated-category', + entityIds: ['ag-grid', 'tanstack-table', 'mui-data-grid'], + featured: true, + public: true, + popularComparison: true, + }, + { + id: 'forms', + title: 'Forms', + kind: 'curated-category', + entityIds: ['react-hook-form', 'tanstack-form', 'conform'], + featured: true, + public: true, + popularComparison: true, + }, + { + id: 'virtualization', + title: 'Virtualization', + kind: 'curated-category', + entityIds: ['react-virtualized', 'react-window', 'tanstack-virtual'], + featured: true, + public: true, + popularComparison: true, + }, + { + id: 'state-management', + title: 'State Management', + kind: 'curated-category', + entityIds: ['redux', 'zustand', 'jotai', 'valtio'], + featured: true, + public: true, + popularComparison: true, + }, + { + id: 'build-tools', + title: 'Build Tools', + kind: 'curated-category', + entityIds: ['webpack', 'vite', 'rollup', 'esbuild', 'rspack'], + featured: true, + public: true, + popularComparison: true, + }, + { + id: 'validation', + title: 'Validation', + kind: 'curated-category', + entityIds: ['zod', 'valibot', 'yup'], + featured: true, + public: true, + popularComparison: true, + }, + { + id: 'frameworks', + title: 'Frameworks', + kind: 'curated-category', + entityIds: ['react', 'vue', 'angular-core', 'svelte', 'solid-js'], + featured: true, + public: true, + popularComparison: true, + }, + { + id: 'all-tanstack', + title: 'All TanStack Libraries', + kind: 'curated-tanstack', + entityIds: [ + 'tanstack-query', + 'tanstack-router', + 'tanstack-table', + 'tanstack-form', + 'tanstack-virtual', + ], + featured: true, + public: true, + popularComparison: true, + }, + { + id: 'javascript-ecosystem-leaders', + title: 'JavaScript Ecosystem Leaders', + kind: 'curated-benchmark', + entityIds: [ + 'react', + 'vue', + 'angular-core', + 'svelte', + 'vite', + 'webpack', + 'rollup', + 'esbuild', + 'react-router', + 'tanstack-query', + 'apollo-client', + 'redux', + 'zustand', + 'react-hook-form', + 'zod', + ], + featured: true, + public: true, + benchmark: true, + }, +] +``` + +## Starter Rollups + +These are the new reusable grouping layer. + +```ts +export const rollups: NpmRollup[] = [ + { + id: 'tanstack-ecosystem', + title: 'TanStack Ecosystem', + kind: 'ecosystem', + entityIds: [ + 'tanstack-query', + 'tanstack-router', + 'tanstack-table', + 'tanstack-form', + 'tanstack-virtual', + ], + membershipMode: 'exclusive', + color: '#FF4500', + public: true, + }, + { + id: 'data-fetching-ecosystem', + title: 'Data Fetching Ecosystem', + kind: 'category', + entityIds: ['tanstack-query', 'swr', 'apollo-client', 'trpc-client'], + membershipMode: 'overlap', + public: true, + }, + { + id: 'router-ecosystem', + title: 'Router Ecosystem', + kind: 'category', + entityIds: ['react-router', 'tanstack-router', 'wouter'], + membershipMode: 'overlap', + public: true, + }, + { + id: 'javascript-ecosystem-index', + title: 'JavaScript Ecosystem Index', + kind: 'benchmark', + entityIds: [ + 'react', + 'vue', + 'angular-core', + 'svelte', + 'vite', + 'webpack', + 'rollup', + 'esbuild', + 'react-router', + 'tanstack-query', + 'redux', + 'react-hook-form', + 'zod', + ], + membershipMode: 'overlap', + public: true, + }, +] +``` + +Future ecosystem rollups can include: + +- `vercel-ecosystem` +- `remix-ecosystem` +- `shopify-ecosystem` + +Those should be explicit editorial definitions, not guessed from npm scopes. + +## Notes On Specific TanStack Entities + +### Query + +- Roll up `react-query` into `@tanstack/react-query`. +- This is a clear lineage case. + +### Table + +- Roll up `react-table` into `@tanstack/react-table`. +- This is also a clear lineage case. + +### Virtual + +- Roll up `react-virtual` into `@tanstack/react-virtual`. +- Clear lineage case. + +### Router + +- Do not automatically roll `react-location` into `@tanstack/react-router` in v1. +- They are adjacent and related, but this one is more semantically debatable than Query, Table, or Virtual. +- Keep this as an explicit later decision if we want lineage continuity there. + +### Form + +- It may be useful to define separate tracked entities for `@tanstack/form-core` and `@tanstack/react-form` later. +- For now, a single `TanStack Form` entity is probably fine if the goal is product-level visibility. + +## Migration From Existing Popular Comparisons + +Recommended path: + +1. Create the entity, rollup, and watchlist registry. +2. Rebuild `getPopularComparisons()` from curated watchlists and rollups marked for that purpose. +3. Keep route behavior and `packageGroup` format unchanged at first. +4. Add optional rollup grouping to chart UI later. +5. Remove duplication once parity looks good. + +## Open Decisions + +1. How aggressive should we be about rolling up framework adapters into one product-level TanStack entity? +2. Which rollups should be exclusive versus overlap-friendly? +3. Should benchmark lists include only `benchmarkEligible` entities or also allow manual exceptions? +4. Should `JavaScript Ecosystem Leaders` aim for 25, 50, or 100 entities in v1? +5. Which watchlists and rollups should be exposed publicly on day one versus internal-only at first? + +## Immediate Next Steps + +- Convert this draft into a real `src/utils/npm-watchlists.ts` module. +- Add real rollup definitions for ecosystem and benchmark views. +- Fill out the tracked entity registry beyond the starter set. +- Rebuild stale popular comparisons from the registry. +- Draft the first larger benchmark roster separately. diff --git a/docs/proposals/npm-watchlists-and-weekly-digests.md b/docs/proposals/npm-watchlists-and-weekly-digests.md new file mode 100644 index 000000000..9abc6e304 --- /dev/null +++ b/docs/proposals/npm-watchlists-and-weekly-digests.md @@ -0,0 +1,665 @@ +# Proposal: NPM Watchlists and Weekly Digests + +## North Star + +Turn TanStack NPM Stats into a durable market map for JavaScript libraries, not just a charting tool. + +Users should be able to follow a set of libraries, understand who is actually gaining or losing ground inside that set, and get a concise weekly digest that surfaces meaningful movement without reacting to noisy daily swings. + +## Why This Matters + +- Raw npm download charts are useful, but they do not answer "who is winning?" +- Raw download growth is increasingly misleading because the whole ecosystem keeps growing. +- Users care about cohorts, rankings, trend changes, and share shifts more than isolated download counts. +- TanStack already has fast comparison UX and historical npm data. This feature turns that into a repeatable product. + +## Product Outcome + +Add rollups and watchlists that users can explore, compare, and subscribe to by email once a week. + +Each digest should answer: + +- Which libraries moved up or down? +- Which libraries gained or lost share? +- Which crossovers happened? +- Which trends look sustained instead of noisy? + +## Core Product Principles + +- `packages -> entities -> rollups -> watchlists` should be the core model. +- Watchlists are the user-facing saved and subscribed surface. +- Rollups are reusable editorial groupings for charts, rankings, and ecosystem views. +- Popular comparisons are just one derived view, not the source of truth. +- Rankings should be cohort-aware, not framed as vague "global npm rank". +- Normalization should favor share and relative outperformance, not raw download growth. +- Long-term rollups must work across legacy package names and package renames. +- Weekly summaries should highlight meaningful movement, not short-term volatility. + +## User Stories + +1. As a user, I can subscribe to a curated watchlist like `Data Fetching` or `Build Tools`. +2. As a user, I can save a custom watchlist from the current `/stats/npm` comparison. +3. As a user, I can follow a logical library even if its npm history spans multiple package names. +4. As a user, I can receive a weekly digest showing rank changes, share changes, and notable crossovers. +5. As a user, I can look back across months or years and see how the watchlist evolved. +6. As a user, I can compare larger ecosystem rollups like `TanStack` vs `Vercel` vs `Remix`. +7. As a user, I can plot entities grouped by rollup on charts. + +## Product Shape + +### 1. Tracked entities + +The atomic unit should not be a single npm package. It should be a logical tracked entity. + +Example: + +```ts +{ + id: 'tanstack-query', + label: 'TanStack Query', + packages: ['@tanstack/react-query', 'react-query'], +} +``` + +This gives us: + +- continuity across legacy/current package names +- stable ranking entities +- cleaner digest language +- long-term rollups without losing historical identity + +### 2. Rollups + +A rollup is a reusable grouped view of tracked entities. + +Examples: + +- `tanstack-ecosystem` +- `vercel-ecosystem` +- `remix-ecosystem` +- `data-fetching-ecosystem` +- `router-ecosystem` + +Rollups let us: + +- compare larger ecosystems on charts +- group entities visually inside one watchlist +- create reusable market-map views without copying entity membership everywhere +- support ecosystem-vs-ecosystem reporting in digests later + +### 3. Watchlists + +A watchlist is a named saved surface for ranking, digesting, and charting. + +A watchlist can contain: + +- entities directly +- rollups +- or both, depending on the UX we want + +Recommended watchlist types: + +- Curated category watchlists +- Curated benchmark watchlists +- TanStack-specific watchlists +- Ecosystem watchlists +- User-created custom watchlists + +### 4. Weekly digest + +Each watchlist can produce a weekly digest with: + +- current rank +- previous rank +- rank delta +- share of watchlist +- share delta +- trailing 4-week trend +- notable crossovers +- link back to the live chart + +Future digest expansions: + +- rollup-vs-rollup share changes +- entity movement inside a rollup +- ecosystem momentum summaries + +## Current Model Draft + +This is the current preferred abstraction stack: + +- `package`: raw npm package history +- `entity`: one logical library or product across one or more packages +- `rollup`: reusable editorial grouping of entities +- `watchlist`: a saved and subscribable surface for charts, rankings, and digests + +Current preferred type direction: + +```ts +export type NpmTrackedEntity = { + id: string + label: string + shortLabel?: string + description?: string + categories: Array< + | 'tanstack' + | 'data-fetching' + | 'routing' + | 'state' + | 'table' + | 'form' + | 'virtualization' + | 'testing' + | 'styling' + | 'build' + | 'validation' + | 'docs' + | 'framework' + | 'animation' + | 'tooling' + | 'database' + > + color?: string + packages: Array<{ + name: string + from?: string + to?: string + }> + lineageStrategy?: 'sum-all' | 'time-bounded' + benchmarkEligible?: boolean + popularComparisonEligible?: boolean + hidden?: boolean +} + +export type NpmRollup = { + id: string + title: string + description?: string + kind: 'ecosystem' | 'category' | 'benchmark' | 'editorial' + entityIds: string[] + membershipMode?: 'exclusive' | 'overlap' + color?: string + public?: boolean +} + +export type NpmWatchlist = { + id: string + title: string + description?: string + kind: 'curated-category' | 'curated-benchmark' | 'curated-tanstack' + entityIds?: string[] + rollupIds?: string[] + featured?: boolean + public?: boolean + popularComparison?: boolean + benchmark?: boolean +} +``` + +Important modeling notes: + +- categories belong on entities as `categories: string[]`, not a single `category` +- watchlists are still the primary curated product surface +- rollups are the reusable grouping abstraction +- popular comparisons should be derived from the registry, not hand-maintained independently +- v1 can mostly use `lineageStrategy: 'sum-all'` +- the model should still leave room for future time-bounded lineage + +## Current Decisions + +These are the main decisions made so far and should be preserved in any handoff. + +- Do not frame the feature as "global npm rank" in v1. +- Rank within a defined cohort, watchlist, or benchmark basket. +- Use trailing 28-day downloads for ranking. +- Use share of watchlist as the primary normalization lens. +- Use relative growth vs watchlist median as a secondary lens. +- Defer anomaly detection. +- Prefer conservative digest-oriented trend signals over loose anomaly alerts. +- Build curated entities and watchlists separately from current `popular comparisons`. +- Include legacy package rollups where lineage is clear. +- Preserve compatibility with long-term and multi-year rollups. +- Treat rollups as first-class because ecosystem-vs-ecosystem views are valuable. +- Allow entities to belong to multiple categories. +- Allow some rollups to overlap and some to be exclusive. + +## Lineage Decisions So Far + +These are the explicit lineage calls already discussed. + +- Roll up `react-query` into `@tanstack/react-query`. +- Roll up `react-table` into `@tanstack/react-table`. +- Roll up `react-virtual` into `@tanstack/react-virtual`. +- Do not automatically roll `react-location` into `@tanstack/react-router` in v1. +- TanStack Form may eventually need separate entity treatment for `@tanstack/form-core` and `@tanstack/react-form`, but one product-level entity is acceptable for v1. + +## Starter Registry Direction + +These are the concrete examples already drafted and should be treated as the current starting point, not final production data. + +Starter entities: + +- `tanstack-query`: `@tanstack/react-query` + `react-query` +- `swr`: `swr` +- `apollo-client`: `@apollo/client` +- `trpc-client`: `@trpc/client` +- `tanstack-router`: `@tanstack/react-router` +- `react-router`: `react-router` +- `wouter`: `wouter` +- `tanstack-table`: `@tanstack/react-table` + `react-table` +- `ag-grid`: `ag-grid-community` + `ag-grid-enterprise` +- `mui-data-grid`: `@mui/x-data-grid` + `mui-datatables` +- `tanstack-form`: `@tanstack/form-core` + `@tanstack/react-form` +- `react-hook-form`: `react-hook-form` +- `conform`: `@conform-to/dom` +- `tanstack-virtual`: `@tanstack/react-virtual` + `react-virtual` +- `react-window`: `react-window` +- `react-virtualized`: `react-virtualized` +- `redux`: `redux` +- `zustand`: `zustand` +- `jotai`: `jotai` +- `valtio`: `valtio` +- `vite`: `vite` +- `webpack`: `webpack` +- `rollup`: `rollup` +- `esbuild`: `esbuild` +- `rspack`: `@rspack/core` +- `zod`: `zod` +- `valibot`: `valibot` +- `yup`: `yup` +- `react`: `react` +- `vue`: `vue` +- `angular-core`: `@angular/core` +- `svelte`: `svelte` +- `solid-js`: `solid-js` + +Starter watchlists: + +- `data-fetching` +- `routing-react` +- `tables-data-grids` +- `forms` +- `virtualization` +- `state-management` +- `build-tools` +- `validation` +- `frameworks` +- `all-tanstack` +- `javascript-ecosystem-leaders` + +Starter rollups: + +- `tanstack-ecosystem` +- `data-fetching-ecosystem` +- `router-ecosystem` +- `javascript-ecosystem-index` + +Future rollups already discussed: + +- `vercel-ecosystem` +- `remix-ecosystem` +- `shopify-ecosystem` + +## Rollup Semantics + +Rollups should be explicit editorial definitions, not inferred automatically from npm scopes or package naming. + +Two intended rollup modes: + +- `exclusive`: best for aggregate comparisons where double counting would distort the view +- `overlap`: best for discovery, category views, and flexible grouped charting + +Examples: + +- `tanstack-ecosystem` should likely be `exclusive` +- category-style rollups like `router-ecosystem` can be `overlap` + +## Popular Comparisons Migration + +The current `popular comparisons` file is useful but not a good source of truth. + +Migration direction: + +1. Build the entity, rollup, and watchlist registry. +2. Rebuild `getPopularComparisons()` from curated watchlists and rollups flagged for chart use. +3. Keep the existing route behavior and `packageGroup` output shape unchanged at first. +4. Add rollup-aware chart grouping later. +5. Remove duplicated manual comparison definitions once parity looks good. + +## Handoff Notes + +If another agent picks this up, these are the highest-value next steps already implied by the current plan. + +1. Convert the draft model into a real `src/utils/npm-watchlists.ts` module. +2. Expand the entity registry beyond the starter examples. +3. Define the first real ecosystem rollups. +4. Decide the first public benchmark basket, especially `JavaScript Ecosystem Leaders`. +5. Replace stale `popular comparisons` with registry-derived definitions. +6. Design the DB schema around entities, rollups, watchlists, subscriptions, and weekly snapshots. + +## Registry Strategy + +### Entities, rollups, and watchlists should become a first-class registry + +Current `popular comparisons` are useful, but they are not sufficient as the long-term source of truth. + +Problems with using them directly: + +- some are out of date +- some are sized for chart exploration, not ongoing tracking +- some do not include legacy package rollups +- some are too small or too editorial for ranking semantics + +We should create a separate curated registry for entities, rollups, and watchlists and let `popular comparisons` derive from it where useful. + +Suggested source file: + +- `src/utils/npm-watchlists.ts` + +### Why rollups matter + +Rollups are what let this feature graduate from simple package comparison into a real market map. + +Examples: + +- compare `TanStack` vs `Vercel` vs `Remix` +- plot all router-related entities and color them by ecosystem rollup +- compare a product rollup against its category peers +- track how an ecosystem's aggregate share changes over time + +### Starter watchlist categories + +Curated category watchlists: + +- Data Fetching +- Routing +- Tables and Data Grids +- Forms +- State Management +- Virtualization +- Testing +- Styling +- Build Tools +- Validation and Schema +- Motion and Animation +- Meta-frameworks + +Curated benchmark watchlists: + +- JavaScript Ecosystem Leaders +- React Ecosystem Leaders +- JavaScript Infra Index +- Frontend Foundation 50 + +TanStack watchlists: + +- All TanStack Libraries +- TanStack Query Ecosystem +- TanStack Router Ecosystem +- TanStack Table Ecosystem + +Ecosystem rollups and watchlists: + +- TanStack Ecosystem +- Vercel Ecosystem +- Remix Ecosystem +- Router Ecosystem +- Data Fetching Ecosystem + +## Legacy Package Rules + +Legacy packages should usually roll into the same tracked entity when they represent the same product lineage. + +Examples: + +- `react-query` + `@tanstack/react-query` +- `react-table` + `@tanstack/react-table` +- `react-virtual` + `@tanstack/react-virtual` + +Do not assume every rename or adjacent product belongs in the same lineage forever. The model should allow future time-aware lineage rules if simple summing becomes misleading. + +V1 rule: + +- tracked entities use a flat array of package names and simple summed downloads + +Future rule if needed: + +- tracked entities can support time-bounded package membership + +## Ranking and Normalization + +Raw download growth is not a good primary signal because the entire ecosystem is growing. + +### Primary ranking metric + +Use trailing 28-day downloads for rank within a watchlist. + +Why 28-day: + +- smoother than daily or weekly +- still responsive enough for weekly digests +- less sensitive to npm reporting noise + +### Primary normalization metric: share of watchlist + +```txt +share_of_watchlist = entity_28d_downloads / sum(all_watchlist_entity_28d_downloads) +``` + +This should be the main lens in digests because it makes leaders and laggards visible even when the whole cohort is growing. + +### Secondary normalization metric: relative growth vs cohort + +```txt +relative_growth = entity_growth_rate - median_growth_rate_of_watchlist +``` + +This helps answer whether a library is outperforming or underperforming peers. + +### Optional tertiary benchmark: ecosystem index + +Do not use a raw sum of giant packages as the main baseline. + +If we add an ecosystem benchmark, it should come from a stable curated basket like `JavaScript Ecosystem Leaders` and use a median or trimmed-mean growth factor rather than raw weighted sum. + +### Digest metrics to show in v1 + +- rank +- rank delta +- share of watchlist +- share delta +- trailing 4-week trend +- crossover events + +## Trend Detection + +Avoid generic "anomaly detection" in v1. + +The data is too noisy and npm has known reporting weirdness. We already correct obvious outliers and zero-day anomalies in the stats pipeline. + +If we surface trend change signals, they should be conservative and digest-oriented. + +Suggested v1 notable movement rules: + +- rank changed by at least 1 +- share moved by a meaningful threshold +- one entity crossed another and stayed ahead for at least one full digest period +- growth outperformed the cohort median by a meaningful margin + +Possible later signals: + +- sustained acceleration over 4 weeks vs prior 8 to 12 weeks +- sustained deceleration over 4 weeks vs prior 8 to 12 weeks +- held rank breakout after 2 consecutive weeks + +## Long-Term Rollups + +This feature must support multi-year rollups. + +The current architecture is already favorable: + +- npm history is cached as year-based daily chunks +- historical chunks are immutable +- package groups already support rollups across multiple packages + +What we need to preserve: + +- tracked entities, not single-package rows +- reusable rollups on top of tracked entities +- raw historical chunks as source of truth +- derived weekly or monthly snapshots for cheap leaderboard queries + +Recommended derived data: + +- weekly watchlist snapshots for digest generation and rank history +- optional monthly snapshots for faster long-range reporting + +This keeps the system flexible: + +- raw chunks allow recomputation when formulas change +- snapshots keep digests and rank-history queries cheap +- rollups can be recalculated without changing the raw source data + +### Rollup views to support later + +- yearly rollup trend lines +- rank history by entity within a rollup +- ecosystem-vs-ecosystem comparisons +- grouped charts where entities are colored or faceted by rollup + +## UX Entry Points + +### `/stats/npm` + +- `Save this comparison` +- `Follow this watchlist` +- `Subscribe to weekly digest` + +### Library-specific npm stats pages + +- `Follow this library` +- `Add to watchlist` + +### Account area + +- My watchlists +- Subscriptions +- Digest frequency +- Pause or unsubscribe + +## Data Model Direction + +Suggested tables: + +- `npm_tracked_entities` +- `npm_tracked_entity_packages` +- `npm_rollups` +- `npm_rollup_entities` +- `npm_watchlists` +- `npm_watchlist_items` +- `npm_watchlist_subscriptions` +- `npm_watchlist_weekly_snapshots` +- `npm_digest_sends` + +Notes: + +- tracked entities are the canonical source for rollups and labels +- rollups reference tracked entities +- watchlists can reference tracked entities, rollups, or both +- weekly snapshots are required for stable digest generation and historical ranking views +- digest send records help with idempotency and auditability + +## Implementation Phases + +### Phase 1. Curated registry + +- Define tracked entities, rollups, and curated watchlists in code +- Include legacy package rollups where appropriate +- Refresh and replace stale `popular comparisons` +- Establish one larger benchmark watchlist like `JavaScript Ecosystem Leaders` + +### Phase 2. Snapshot pipeline + +- Compute weekly watchlist snapshots from historical npm data +- Store rank, share, and trailing trend fields +- Make snapshot generation idempotent + +### Phase 3. User-facing watchlists + +- Allow signed-in users to save comparisons as custom watchlists +- Allow subscription management from account surfaces +- Allow subscription from stats pages + +### Phase 4. Weekly digests + +- Generate digest content from weekly snapshots +- Send through Resend +- Include unsubscribe and pause controls + +### Phase 5. Trend signals and benchmark refinement + +- Add conservative trend-change detection +- Add ecosystem index if the curated benchmark proves stable and useful + +## Suggested Implementation Order + +1. Build the curated tracked-entity and watchlist registry. +2. Add reusable rollups for ecosystems and category-level groupings. +3. Replace or derive `popular comparisons` from the new registry. +4. Add database tables for tracked entities, rollups, watchlists, subscriptions, and weekly snapshots. +5. Build the weekly snapshot job on top of existing historical npm chunks. +6. Expose watchlists and rollup grouping in UI without email first. +7. Add weekly digest generation and delivery. +8. Add rank history and long-range watchlist views. +9. Add conservative trend signals only after real snapshot data exists. + +## Success Criteria + +- Users can subscribe to curated watchlists and custom watchlists. +- Users can compare major ecosystem rollups clearly. +- Digests clearly show rank movement and share movement. +- Rankings are stable enough to feel trustworthy week to week. +- Legacy package history rolls up cleanly into one logical entity. +- Multi-year watchlist history remains queryable and understandable. +- We can explain the ranking math in plain English. + +## Non-Goals For V1 + +- claiming true global npm rank across the whole registry +- real-time alerts +- daily digest spam +- loose anomaly detection with low confidence +- overfitting the system to every one-off package rename + +## Open Questions + +1. Which curated watchlists should ship first? +2. How large should `JavaScript Ecosystem Leaders` be? +3. Which legacy package transitions deserve permanent lineage rollups? +4. Which rollups should be exclusive versus allowed to overlap? +5. Should custom watchlists allow raw package groups, tracked entities, rollups, or all three? +6. Should digests be weekly only in v1, or should monthly be supported from day one? +7. Do we want one digest per watchlist or one combined digest across all subscriptions? + +## Progress + +- [ ] Define tracked entity model +- [ ] Define rollup model +- [ ] Draft curated watchlist registry +- [ ] Draft curated ecosystem rollups +- [ ] Audit and refresh current popular comparisons +- [ ] Define lineage rules for legacy package rollups +- [ ] Design DB schema +- [ ] Build weekly snapshot job +- [ ] Build watchlist UI surfaces +- [ ] Build subscription management +- [ ] Build weekly digest generation +- [ ] Build email delivery and unsubscribe flow +- [ ] Add rank history views +- [ ] Evaluate trend signals after snapshot data accumulates + +## Notes + +- Existing npm stats infrastructure already gives us a strong foundation: historical daily chunks, package grouping, outlier correction, and email transport. +- The biggest risk is product semantics, not raw implementation difficulty. +- The feature will feel credible only if watchlists are curated well and ranking math stays easy to explain. diff --git a/package.json b/package.json index 2dcef0cd8..2a9acabe6 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "type": "module", "scripts": { "dev": "pnpm run with-env vite dev", - "with-env": "dotenv -e .env -e .env.local", + "with-env": "dotenv -e .env.local", "dev:frontend": "pnpm run with-env vite dev", "build": "vite build", "start": "vite start", @@ -44,13 +44,14 @@ "@sentry/browser": "^10.47.0", "@sentry/node": "^10.47.0", "@sentry/tanstackstart-react": "^10.47.0", + "@shopify/hydrogen-react": "^2026.4.0", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.2", - "@tanstack/ai": "^0.10.0", - "@tanstack/ai-anthropic": "^0.7.2", - "@tanstack/ai-client": "^0.7.7", - "@tanstack/ai-openai": "^0.7.3", - "@tanstack/create": "^0.63.2", + "@tanstack/ai": "^0.10.2", + "@tanstack/ai-anthropic": "^0.7.3", + "@tanstack/ai-client": "^0.7.9", + "@tanstack/ai-openai": "^0.7.4", + "@tanstack/create": "^0.63.4", "@tanstack/pacer": "^0.20.1", "@tanstack/react-hotkeys": "^0.9.1", "@tanstack/react-pacer": "^0.21.1", @@ -65,7 +66,7 @@ "@uploadthing/react": "^7.3.3", "@visx/hierarchy": "^3.12.0", "@vitejs/plugin-react": "^6.0.1", - "@vitejs/plugin-rsc": "^0.5.23", + "@vitejs/plugin-rsc": "^0.5.24", "@webcontainer/api": "^1.6.1", "@xstate/react": "^6.1.0", "algoliasearch": "^5.50.0", @@ -120,8 +121,8 @@ "@playwright/test": "^1.59.0", "@shikijs/transformers": "^4.0.2", "@tanstack/devtools-vite": "^0.6.0", - "@tanstack/react-devtools": "^0.10.1", - "@tanstack/react-query-devtools": "^5.96.2", + "@tanstack/react-devtools": "^0.10.2", + "@tanstack/react-query-devtools": "^5.99.0", "@types/hast": "^3.0.4", "@types/node": "^25.5.0", "@types/pg": "^8.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ecc71c1d..00d1ec4d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 5.1.5 '@netlify/vite-plugin-tanstack-start': specifier: ^1.3.2 - version: 1.3.2(@tanstack/react-start@1.167.30(@vitejs/plugin-rsc@0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(babel-plugin-macros@3.1.0)(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.3.4(@tanstack/react-start@1.167.30(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(babel-plugin-macros@3.1.0)(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) '@observablehq/plot': specifier: ^0.6.17 version: 0.6.17 @@ -65,6 +65,9 @@ importers: '@sentry/tanstackstart-react': specifier: ^10.47.0 version: 10.47.0(react@19.2.3)(rollup@4.53.3) + '@shopify/hydrogen-react': + specifier: ^2026.4.0 + version: 2026.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.183.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) @@ -72,20 +75,20 @@ importers: specifier: ^4.2.2 version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/ai': - specifier: ^0.10.0 - version: 0.10.0 + specifier: ^0.10.2 + version: 0.10.2 '@tanstack/ai-anthropic': - specifier: ^0.7.2 - version: 0.7.2(@tanstack/ai@0.10.0)(zod@4.3.6) + specifier: ^0.7.3 + version: 0.7.3(@tanstack/ai@0.10.2)(zod@4.3.6) '@tanstack/ai-client': - specifier: ^0.7.7 - version: 0.7.7 + specifier: ^0.7.9 + version: 0.7.9 '@tanstack/ai-openai': - specifier: ^0.7.3 - version: 0.7.3(@tanstack/ai-client@0.7.7)(@tanstack/ai@0.10.0)(ws@8.20.0)(zod@4.3.6) + specifier: ^0.7.4 + version: 0.7.4(@tanstack/ai-client@0.7.9)(@tanstack/ai@0.10.2)(ws@8.20.0)(zod@4.3.6) '@tanstack/create': - specifier: ^0.63.2 - version: 0.63.2(tslib@2.8.1) + specifier: ^0.63.4 + version: 0.63.4(tslib@2.8.1) '@tanstack/pacer': specifier: ^0.20.1 version: 0.20.1 @@ -97,7 +100,7 @@ importers: version: 0.21.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-query': specifier: ^5.96.2 - version: 5.96.2(react@19.2.3) + version: 5.99.0(react@19.2.3) '@tanstack/react-router': specifier: 1.168.17 version: 1.168.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -106,10 +109,10 @@ importers: version: 1.166.13(@tanstack/react-router@1.168.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.168.14)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router-ssr-query': specifier: 1.166.11 - version: 1.166.11(@tanstack/query-core@5.96.2)(@tanstack/react-query@5.96.2(react@19.2.3))(@tanstack/react-router@1.168.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.168.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.166.11(@tanstack/query-core@5.99.0)(@tanstack/react-query@5.99.0(react@19.2.3))(@tanstack/react-router@1.168.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.168.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': specifier: 1.167.30 - version: 1.167.30(@vitejs/plugin-rsc@0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.167.30(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/react-start-server': specifier: 1.166.35 version: 1.166.35(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -129,8 +132,8 @@ importers: specifier: ^6.0.1 version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitejs/plugin-rsc': - specifier: ^0.5.23 - version: 0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) + specifier: ^0.5.24 + version: 0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) '@webcontainer/api': specifier: ^1.6.1 version: 1.6.1 @@ -289,11 +292,11 @@ importers: specifier: ^0.6.0 version: 0.6.0(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/react-devtools': - specifier: ^0.10.1 - version: 0.10.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12) + specifier: ^0.10.2 + version: 0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12) '@tanstack/react-query-devtools': - specifier: ^5.96.2 - version: 5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3) + specifier: ^5.99.0 + version: 5.99.0(@tanstack/react-query@5.99.0(react@19.2.3))(react@19.2.3) '@types/hast': specifier: ^3.0.4 version: 3.0.4 @@ -1234,6 +1237,12 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@google/model-viewer@4.2.0': + resolution: {integrity: sha512-RjpAI5cLs9CdvPcMRsOs8Bea/lNmGTTyaPyl16o9Fv6Qn8VSpgBMmXFr/11yb0hTrsojp2dOACEcY77R8hVUVA==} + engines: {node: '>=6.0.0'} + peerDependencies: + three: ^0.182.0 + '@hono/node-server@1.19.12': resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==} engines: {node: '>=18.14.1'} @@ -1552,6 +1561,12 @@ packages: peerDependencies: tslib: '2' + '@lit-labs/ssr-dom-shim@1.5.1': + resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} + + '@lit/reactive-element@2.1.2': + resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + '@mapbox/node-pre-gyp@2.0.3': resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} @@ -1642,16 +1657,16 @@ packages: engines: {node: '>=18.14.0'} hasBin: true - '@netlify/db-dev@0.7.0': - resolution: {integrity: sha512-xB2ciUJsWFw34DtMMmAh19qGUJy1N5mFAi6yYFFqeBlnd8StSxoUblXcqVZcQJLXm68OAxSxkfGf1qUucIDyMA==} + '@netlify/database-dev@0.8.0': + resolution: {integrity: sha512-q53dx7QgLMMN3vKRFvX/guDOPXscYaF4qtHoG0JYZorC0vcJagw6s3nV8cNob9sCjA3Tj/BNf7MnWwOSF0E1Cw==} engines: {node: '>=20.6.1'} '@netlify/dev-utils@4.4.3': resolution: {integrity: sha512-VkMD8YACshR6pHgoub6nikkI+SQVdhjVvLsOK2ZSpN2wMlDHdsD8uRjESfzv/yYfq5jlsGskfx1cf1FUurWt9A==} engines: {node: ^18.14.0 || >=20} - '@netlify/dev@4.16.4': - resolution: {integrity: sha512-IhyLtiEgOFCwpW/qYqCnY0LSwmw4PNgu4hJYIddLpIr/RPHAsDN+Ni3hEEW9RH8wkmvYmbc+xtxHPQZdyDUSgg==} + '@netlify/dev@4.17.0': + resolution: {integrity: sha512-DumVB+3VdIXXGWKrQVM4ouwahhtajtfC1IU8bDt472P3+zDoceai3FYrTfBq4LbWq6ojZXGqf8XWsJHO6rd/0A==} engines: {node: '>=20.6.1'} '@netlify/edge-bundler@14.9.19': @@ -1669,14 +1684,18 @@ packages: resolution: {integrity: sha512-xkVcTcpAuQKAY5GXKOjPTIct5Mz53NPHXOasggA+LTAxDDV4ohqSM8BIaXh1SgbcniHZyFhBqhc5hxZ+fFz5bQ==} engines: {node: '>=18.0.0'} - '@netlify/functions-dev@1.2.4': - resolution: {integrity: sha512-Ejrb+1HL11zgVgaaMHWukH4hOXFy3FUtue8hD5rgBiLWeGp03uOlGehmbRz47FXvGBMK7Z4t7HZRZU/vpaN5+g==} + '@netlify/functions-dev@1.2.5': + resolution: {integrity: sha512-IRskgi6cexUkFtU0Da/OiiUyvyKOzxNsf16HN9iRIGGZUQ4AcyXwJPqVsquP9jHluY3Ck8bDsnxXnpOsaT4ENg==} engines: {node: '>=20.6.1'} '@netlify/functions@5.1.5': resolution: {integrity: sha512-mhTl6x3TWoRwNgz8HZ9zvSR9OHB/hDEA6VinBmWY5ubgycKNCerf6XyFaFnujH2Ygx3c32yg6QOOr1v9y8euug==} engines: {node: '>=18.0.0'} + '@netlify/functions@5.2.0': + resolution: {integrity: sha512-Pj93qeQd1tkQ5xm9gWJZmBf/1riLYqYHc0OzFukrJomrj82Ott53Rr/Q88H1ms5cF+P5QXRKWmA2JSxSybKfjA==} + engines: {node: '>=18.0.0'} + '@netlify/headers-parser@9.0.3': resolution: {integrity: sha512-KNzC9RaKDwJVS44iTK6JxNA6LeXH0PUw0pLktWpmMVI/0FR98bvxaHcAisjHqbThAjxL9QjL1UZh0KzHCkxpNQ==} engines: {node: '>=18.14.0'} @@ -1725,8 +1744,8 @@ packages: resolution: {integrity: sha512-yD20EizHJDQxajJ66Vo8RTwLwR2jMNVxufPG8MHd2AScX8jW4z0VPnnJHArq2GYPFTFZRHmiAhDrXr5m8zof6w==} engines: {node: ^18.14.0 || >=20} - '@netlify/vite-plugin-tanstack-start@1.3.2': - resolution: {integrity: sha512-Dg3CfK7O+P52sX+iQVLTQNH6+7DfhBK76el8TYAiQLfdc7j5tJgWwXyhMtrBpaylGOdqbYwZfVFBI+e8asDsPg==} + '@netlify/vite-plugin-tanstack-start@1.3.4': + resolution: {integrity: sha512-MjYTiVTkD2J1dPMU6eKAMWPZvfOJZm5aM7065YWQVOJHidsKvSYg5fuI4h1vYZmQVUJwOhJFG+6rv4F9S3ZZWQ==} engines: {node: ^22.12.0 || >=24.0.0} peerDependencies: '@tanstack/react-start': '>=1.132.0' @@ -1738,8 +1757,8 @@ packages: '@tanstack/solid-start': optional: true - '@netlify/vite-plugin@2.11.2': - resolution: {integrity: sha512-R5p/qWdUsbg2QBwCLcJuCwBPYog3VH6yezXc18rI2I+OKrdg+kzv35ZE2QkvC1junLITqbaIKYx8xYykkohQkw==} + '@netlify/vite-plugin@2.11.4': + resolution: {integrity: sha512-Q1hbuL6f4hghao17Dk3dGIE20oBzvxACdCxox2FKYHR0gypcq7aJW+uoueTkcZuB1ZE7K5xSAQjmKRjOiaCV1g==} engines: {node: ^20.6.1 || >=22} peerDependencies: vite: ^5 || ^6 || ^7 || ^8 @@ -2925,8 +2944,8 @@ packages: '@rolldown/pluginutils@1.0.0-rc.12': resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} - '@rolldown/pluginutils@1.0.0-rc.13': - resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==} + '@rolldown/pluginutils@1.0.0-rc.15': + resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -3247,6 +3266,13 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@shopify/hydrogen-react@2026.4.0': + resolution: {integrity: sha512-RXtMkkZAJFv43BcNR2CNIkVbUqT28nU9uzJ05lewkX9yq2C7Vmf5AC2jLov+tevMYFqTPOQRiZYuxtym4UnKSA==} + peerDependencies: + react: ^18.3.1 || ~19.0.3 || ~19.1.4 || ^19.2.3 + react-dom: ^18.3.1 || ~19.0.3 || ~19.1.4 || ^19.2.3 + vite: ^5.1.0 || ^6.2.1 + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -3400,33 +3426,33 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/ai-anthropic@0.7.2': - resolution: {integrity: sha512-ZV01BPuplBOhnnzPpFqoPBWLQNGFSBPkIUaE23NqGrjzyKfeWed4mkTl/Q5cg2rF1VDTS8iOYXWME206kHuiPA==} + '@tanstack/ai-anthropic@0.7.3': + resolution: {integrity: sha512-toWuZSghJDo+eCNyjYJmumeX7prAOjiOfb1sPFQiJ81IExFUU731IejFUzM5evXuoUrXhNI1elPVOC3IYUlwHg==} peerDependencies: - '@tanstack/ai': ^0.10.0 + '@tanstack/ai': ^0.10.1 zod: ^4.0.0 - '@tanstack/ai-client@0.7.7': - resolution: {integrity: sha512-Z4BcVgtYMaeSp46UcRzKWvG2SYC3M0VW2jYaWGKRrTouQ71+3C0DREZr8MYZr1gAkCCcQqqWPejKkYdJ7+cLSQ==} + '@tanstack/ai-client@0.7.9': + resolution: {integrity: sha512-bzB/G08q4fQ4e8EwnesDrZWDVNxggVN4gOG+Twtlzxze18i46BFH0t5PjlFLL6WWCG4VGoRj+ocZ22ebrz+KUA==} - '@tanstack/ai-event-client@0.2.0': - resolution: {integrity: sha512-1O7PbBlSo0gCrv4/bmOvnemZ6Cvf+kZmHqug19JEz8Bj4/anup3GkwvKBk8XzEvPIB+vQb4GL9TLN4Tfo6ZCkA==} + '@tanstack/ai-event-client@0.2.2': + resolution: {integrity: sha512-Dcv2eeVyTDXbv7IPFTl7o1Vy12V06pjSnQLPoHvydYkJGkdD+vvmUFrmtGQiBq/FgW6GT7pPpVPyTW2DJVOEeA==} peerDependencies: - '@tanstack/ai': 0.10.0 + '@tanstack/ai': 0.10.2 - '@tanstack/ai-openai@0.7.3': - resolution: {integrity: sha512-xdxTR7E/BzeQcFte/chDA//2WpZQqDQF4op1TgKNoaTqffW9+JMlSubaZbMkawtJoImVCGjZoPVkUfb8aRldNA==} + '@tanstack/ai-openai@0.7.4': + resolution: {integrity: sha512-AYPLvrnWbtqj185MtTT9Mo5GWbZQOwQwtfgZCxAslctxv0nL0lQInPEQAkwcMFle+qbadJB8BhJqTnRBAsW+Tg==} peerDependencies: - '@tanstack/ai': ^0.10.0 - '@tanstack/ai-client': ^0.7.7 + '@tanstack/ai': ^0.10.1 + '@tanstack/ai-client': ^0.7.8 zod: ^4.0.0 - '@tanstack/ai@0.10.0': - resolution: {integrity: sha512-b1GDenJs2udQfjFMnAWZ6WX9RLg04O9wC85V4cSD8phuVMJscs+Qp0UkNj7FTc+V1ggqf4Pz1jLFfqjLO2z3mg==} + '@tanstack/ai@0.10.2': + resolution: {integrity: sha512-uy9swoOXgDltFZcaKc9YkRD22fndK0NhJAtOs1vCaugSYOjgOsfT0jnkZ9kswVn+OPXQxiXKQ9PSNKIoKKSP8g==} engines: {node: '>=18'} - '@tanstack/create@0.63.2': - resolution: {integrity: sha512-DDVQhO3Cv9Wulz2h8I17JDO0YkRltg0M54Jn8g3SAwK5oiMX4FcY7xeomlS87B6p3yJJFuv4KaNTpQfNnAbYWg==} + '@tanstack/create@0.63.4': + resolution: {integrity: sha512-LpCU+WrCXKJniyz2OIHmesqEsTQFRTcB0A8+1yiN7Idj8sUjFoTV1YYJJLzrlGUwAGM42IZMOmWt1EgBlPaNRw==} '@tanstack/devtools-client@0.0.6': resolution: {integrity: sha512-f85ZJXJnDIFOoykG/BFIixuAevJovCvJF391LPs6YjBAPhGYC50NWlx1y4iF/UmK5/cCMx+/JqI5SBOz7FanQQ==} @@ -3454,8 +3480,8 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@tanstack/devtools@0.11.1': - resolution: {integrity: sha512-g3nHgVP76kT9190d6O32AjANoEnujLEB+51PDtBzlah8hvKeEygK53cunN+HXhjlfhM4PoOCi8/B96cdJVSnLg==} + '@tanstack/devtools@0.11.2': + resolution: {integrity: sha512-K8+tsBx+ptTLqqd4dOF10B6laj1g+XYImqYZL9n0jBINGaT+sOf17PKV9pbBt8kdbZeIGsHaJ5OZWCyZoHqN4A==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3473,14 +3499,14 @@ packages: resolution: {integrity: sha512-ZNQ1bIL6eUXVKdic0tiImvBVkWrg/IoSK6VIacTrO3d3HAGnd70qFJNJagR/YOJIOw4EKGWnodwpYZkN1pWuVQ==} engines: {node: '>=18'} - '@tanstack/query-core@5.96.2': - resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==} + '@tanstack/query-core@5.99.0': + resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} - '@tanstack/query-devtools@5.96.2': - resolution: {integrity: sha512-vBTB1Qhbm3nHSbEUtQwks/EdcAtFfEapr1WyBW4w2ExYKuXVi3jIxUIHf5MlSltiHuL7zNyUuanqT/7sI2sb6g==} + '@tanstack/query-devtools@5.99.0': + resolution: {integrity: sha512-m4ufXaJ8FjWXw7xDtyzE/6fkZAyQFg9WrbMrUpt8ZecRJx58jiFOZ2lxZMphZdIpAnIeto/S8stbwLKLusyckQ==} - '@tanstack/react-devtools@0.10.1': - resolution: {integrity: sha512-cvcd0EqN7Q2LYatQXxFhOkEa9RUQXZlhXnM1mwuibxmyRX+CMyohUZcgjodtIfgh+RT0Pmvt49liTdZby5ovZw==} + '@tanstack/react-devtools@0.10.2': + resolution: {integrity: sha512-1BmZyxOrI5SqmRJ5MgkYZNNdnlLsJxQRI2YgorrAvcF2MxK6x5RcuStvD8+YlXoMw3JtNukPxoITirKAnKYDQA==} engines: {node: '>=18'} peerDependencies: '@types/react': '>=16.8' @@ -3502,14 +3528,14 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-query-devtools@5.96.2': - resolution: {integrity: sha512-nTFKLGuTOFvmFRvcyZ3ArWC/DnMNPoBh6h/2yD6rsf7TCTJCQt+oUWOp2uKPTIuEPtF/vN9Kw5tl5mD1Kbposw==} + '@tanstack/react-query-devtools@5.99.0': + resolution: {integrity: sha512-CqqX7LCU9yOfCY/vBURSx2YSD83ryfX+QkfkaKionTfg1s2Hdm572Ro99gW3QPoJjzvsj1HM4pnN4nbDy3MXKA==} peerDependencies: - '@tanstack/react-query': ^5.96.2 + '@tanstack/react-query': ^5.99.0 react: ^18 || ^19 - '@tanstack/react-query@5.96.2': - resolution: {integrity: sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==} + '@tanstack/react-query@5.99.0': + resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==} peerDependencies: react: ^18 || ^19 @@ -3827,8 +3853,8 @@ packages: '@types/mysql@2.15.27': resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} - '@types/node@22.19.15': - resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} @@ -3987,8 +4013,8 @@ packages: babel-plugin-react-compiler: optional: true - '@vitejs/plugin-rsc@0.5.23': - resolution: {integrity: sha512-CV6kWPE4E241qDStwK3ErYjuZqW1i1xun3/P1wsm94RJoActLTrQsGzGsf75ioeVxEK0roPqLGhcV2WlSlPePQ==} + '@vitejs/plugin-rsc@0.5.24': + resolution: {integrity: sha512-FQ7o1Zf1GUB8L5qlIuV2mvIv/KahG2qUYW2gMpxyIN3zF7voDsfvA/t8w/TLjYC0T6p3JwMnK3N+YzMGf/m75A==} peerDependencies: react: '*' react-dom: '*' @@ -4039,6 +4065,9 @@ packages: resolution: {integrity: sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg==} engines: {node: '>=18.0.0'} + '@xstate/fsm@2.0.0': + resolution: {integrity: sha512-p/zcvBMoU2ap5byMefLkR+AM+Eh99CU/SDEQeccgKlmFNOMDwphaRGqdk+emvel/SaGZ7Rf9sDvzAplLzLdEVQ==} + '@xstate/react@6.1.0': resolution: {integrity: sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw==} peerDependencies: @@ -4161,6 +4190,9 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -4531,8 +4563,8 @@ packages: cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} - cookie-es@2.0.0: - resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cookie-es@2.0.1: + resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} @@ -5513,6 +5545,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.13.2: + resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gray-matter@4.0.3: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} @@ -5997,6 +6033,9 @@ packages: js-image-generator@1.0.4: resolution: {integrity: sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -6189,6 +6228,15 @@ packages: resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==} hasBin: true + lit-element@4.2.2: + resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} + + lit-html@3.3.2: + resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} + + lit@3.3.2: + resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + load-json-file@4.0.0: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} engines: {node: '>=4'} @@ -7193,6 +7241,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + regexparam@2.0.2: + resolution: {integrity: sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==} + engines: {node: '>=8'} + rehype-autolink-headings@7.1.0: resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} @@ -7277,6 +7329,11 @@ packages: engines: {node: '>= 0.4'} hasBin: true + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + resolve@2.0.0-next.6: resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} engines: {node: '>= 0.4'} @@ -7389,14 +7446,14 @@ packages: resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} engines: {node: '>=20.0.0'} - seroval-plugins@1.5.1: - resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 - seroval@1.5.1: - resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} engines: {node: '>=10'} serve-static@2.2.1: @@ -8201,6 +8258,10 @@ packages: resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} engines: {node: '>= 12.0.0'} + worktop@0.7.3: + resolution: {integrity: sha512-WBHP1hk8pLP7ahAw13fugDWcO0SUAOiCD6DHT/bfLWoCIA/PL9u7GKdudT2nGZ8EGR1APbGCAI6ZzKG1+X+PnQ==} + engines: {node: '>=12'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -8992,6 +9053,12 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@google/model-viewer@4.2.0(three@0.183.2)': + dependencies: + '@monogrid/gainmap-js': 3.4.0(three@0.183.2) + lit: 3.3.2 + three: 0.183.2 + '@hono/node-server@1.19.12(hono@4.12.9)': dependencies: hono: 4.12.9 @@ -9262,6 +9329,12 @@ snapshots: '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) tslib: 2.8.1 + '@lit-labs/ssr-dom-shim@1.5.1': {} + + '@lit/reactive-element@2.1.2': + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@mapbox/node-pre-gyp@2.0.3': dependencies: consola: 3.4.2 @@ -9335,7 +9408,7 @@ snapshots: '@neondatabase/serverless@1.0.2': dependencies: - '@types/node': 22.19.15 + '@types/node': 22.19.17 '@types/pg': 8.20.0 optional: true @@ -9392,7 +9465,7 @@ snapshots: yargs: 17.7.2 zod: 4.3.6 - '@netlify/db-dev@0.7.0': + '@netlify/database-dev@0.8.0': dependencies: '@electric-sql/pglite': 0.3.16 pg-gateway: 0.3.0-beta.4 @@ -9415,15 +9488,15 @@ snapshots: uuid: 13.0.0 write-file-atomic: 5.0.1 - '@netlify/dev@4.16.4(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))': + '@netlify/dev@4.17.0(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))': dependencies: '@netlify/ai': 0.4.1 '@netlify/blobs': 10.7.4 '@netlify/config': 24.4.4 - '@netlify/db-dev': 0.7.0 + '@netlify/database-dev': 0.8.0 '@netlify/dev-utils': 4.4.3 '@netlify/edge-functions-dev': 1.0.16 - '@netlify/functions-dev': 1.2.4(rollup@4.53.3) + '@netlify/functions-dev': 1.2.5(rollup@4.53.3) '@netlify/headers': 2.1.8 '@netlify/images': 1.3.7(@netlify/blobs@10.7.4)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2)) '@netlify/redirects': 3.1.10 @@ -9496,11 +9569,11 @@ snapshots: dependencies: '@netlify/types': 2.6.0 - '@netlify/functions-dev@1.2.4(rollup@4.53.3)': + '@netlify/functions-dev@1.2.5(rollup@4.53.3)': dependencies: '@netlify/blobs': 10.7.4 '@netlify/dev-utils': 4.4.3 - '@netlify/functions': 5.1.5 + '@netlify/functions': 5.2.0 '@netlify/zip-it-and-ship-it': 14.5.3(rollup@4.53.3) cron-parser: 4.9.0 decache: 4.6.2 @@ -9523,6 +9596,10 @@ snapshots: dependencies: '@netlify/types': 2.6.0 + '@netlify/functions@5.2.0': + dependencies: + '@netlify/types': 2.6.0 + '@netlify/headers-parser@9.0.3': dependencies: '@iarna/toml': 2.2.5 @@ -9608,12 +9685,12 @@ snapshots: '@netlify/types@2.6.0': {} - '@netlify/vite-plugin-tanstack-start@1.3.2(@tanstack/react-start@1.167.30(@vitejs/plugin-rsc@0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(babel-plugin-macros@3.1.0)(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': + '@netlify/vite-plugin-tanstack-start@1.3.4(@tanstack/react-start@1.167.30(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(babel-plugin-macros@3.1.0)(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@netlify/vite-plugin': 2.11.2(babel-plugin-macros@3.1.0)(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) + '@netlify/vite-plugin': 2.11.4(babel-plugin-macros@3.1.0)(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3) optionalDependencies: - '@tanstack/react-start': 1.167.30(@vitejs/plugin-rsc@0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/react-start': 1.167.30(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -9641,9 +9718,9 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@2.11.2(babel-plugin-macros@3.1.0)(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': + '@netlify/vite-plugin@2.11.4(babel-plugin-macros@3.1.0)(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@netlify/dev': 4.16.4(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2)) + '@netlify/dev': 4.17.0(rollup@4.53.3)(uploadthing@7.7.4(express@5.2.1)(h3@1.15.10)(tailwindcss@4.2.2)) '@netlify/dev-utils': 4.4.3 dedent: 1.7.2(babel-plugin-macros@3.1.0) vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3) @@ -9783,7 +9860,7 @@ snapshots: '@opentelemetry/api-logs@0.203.0': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs@0.207.0': dependencies: @@ -10747,7 +10824,7 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.12': {} - '@rolldown/pluginutils@1.0.0-rc.13': {} + '@rolldown/pluginutils@1.0.0-rc.15': {} '@rolldown/pluginutils@1.0.0-rc.7': {} @@ -11063,6 +11140,20 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@shopify/hydrogen-react@2026.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.183.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@google/model-viewer': 4.2.0(three@0.183.2) + '@xstate/fsm': 2.0.0 + ast-v8-to-istanbul: 0.3.12 + graphql: 16.13.2 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + type-fest: 4.41.0 + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3) + worktop: 0.7.3 + transitivePeerDependencies: + - three + '@sindresorhus/merge-streams@4.0.0': {} '@so-ric/colorspace@1.1.6': @@ -11191,37 +11282,37 @@ snapshots: tailwindcss: 4.2.2 vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3) - '@tanstack/ai-anthropic@0.7.2(@tanstack/ai@0.10.0)(zod@4.3.6)': + '@tanstack/ai-anthropic@0.7.3(@tanstack/ai@0.10.2)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.6) - '@tanstack/ai': 0.10.0 + '@tanstack/ai': 0.10.2 zod: 4.3.6 - '@tanstack/ai-client@0.7.7': + '@tanstack/ai-client@0.7.9': dependencies: - '@tanstack/ai': 0.10.0 - '@tanstack/ai-event-client': 0.2.0(@tanstack/ai@0.10.0) + '@tanstack/ai': 0.10.2 + '@tanstack/ai-event-client': 0.2.2(@tanstack/ai@0.10.2) - '@tanstack/ai-event-client@0.2.0(@tanstack/ai@0.10.0)': + '@tanstack/ai-event-client@0.2.2(@tanstack/ai@0.10.2)': dependencies: - '@tanstack/ai': 0.10.0 + '@tanstack/ai': 0.10.2 '@tanstack/devtools-event-client': 0.4.3 - '@tanstack/ai-openai@0.7.3(@tanstack/ai-client@0.7.7)(@tanstack/ai@0.10.0)(ws@8.20.0)(zod@4.3.6)': + '@tanstack/ai-openai@0.7.4(@tanstack/ai-client@0.7.9)(@tanstack/ai@0.10.2)(ws@8.20.0)(zod@4.3.6)': dependencies: - '@tanstack/ai': 0.10.0 - '@tanstack/ai-client': 0.7.7 + '@tanstack/ai': 0.10.2 + '@tanstack/ai-client': 0.7.9 openai: 6.33.0(ws@8.20.0)(zod@4.3.6) zod: 4.3.6 transitivePeerDependencies: - ws - '@tanstack/ai@0.10.0': + '@tanstack/ai@0.10.2': dependencies: - '@tanstack/ai-event-client': 0.2.0(@tanstack/ai@0.10.0) + '@tanstack/ai-event-client': 0.2.2(@tanstack/ai@0.10.2) partial-json: 0.1.7 - '@tanstack/create@0.63.2(tslib@2.8.1)': + '@tanstack/create@0.63.4(tslib@2.8.1)': dependencies: ejs: 3.1.10 execa: 9.6.1 @@ -11274,7 +11365,7 @@ snapshots: - supports-color - utf-8-validate - '@tanstack/devtools@0.11.1(csstype@3.2.3)(solid-js@1.9.12)': + '@tanstack/devtools@0.11.2(csstype@3.2.3)(solid-js@1.9.12)': dependencies: '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.12) '@solid-primitives/keyboard': 1.3.5(solid-js@1.9.12) @@ -11301,13 +11392,13 @@ snapshots: '@tanstack/devtools-event-client': 0.4.3 '@tanstack/store': 0.9.3 - '@tanstack/query-core@5.96.2': {} + '@tanstack/query-core@5.99.0': {} - '@tanstack/query-devtools@5.96.2': {} + '@tanstack/query-devtools@5.99.0': {} - '@tanstack/react-devtools@0.10.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12)': + '@tanstack/react-devtools@0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12)': dependencies: - '@tanstack/devtools': 0.11.1(csstype@3.2.3)(solid-js@1.9.12) + '@tanstack/devtools': 0.11.2(csstype@3.2.3)(solid-js@1.9.12) '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) react: 19.2.3 @@ -11332,15 +11423,15 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@tanstack/react-query-devtools@5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3)': + '@tanstack/react-query-devtools@5.99.0(@tanstack/react-query@5.99.0(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/query-devtools': 5.96.2 - '@tanstack/react-query': 5.96.2(react@19.2.3) + '@tanstack/query-devtools': 5.99.0 + '@tanstack/react-query': 5.99.0(react@19.2.3) react: 19.2.3 - '@tanstack/react-query@5.96.2(react@19.2.3)': + '@tanstack/react-query@5.99.0(react@19.2.3)': dependencies: - '@tanstack/query-core': 5.96.2 + '@tanstack/query-core': 5.99.0 react: 19.2.3 '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.168.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.168.14)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': @@ -11354,12 +11445,12 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/react-router-ssr-query@1.166.11(@tanstack/query-core@5.96.2)(@tanstack/react-query@5.96.2(react@19.2.3))(@tanstack/react-router@1.168.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.168.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-router-ssr-query@1.166.11(@tanstack/query-core@5.99.0)(@tanstack/react-query@5.99.0(react@19.2.3))(@tanstack/react-router@1.168.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.168.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/query-core': 5.96.2 - '@tanstack/react-query': 5.96.2(react@19.2.3) + '@tanstack/query-core': 5.99.0 + '@tanstack/react-query': 5.99.0(react@19.2.3) '@tanstack/react-router': 1.168.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-ssr-query-core': 1.167.1(@tanstack/query-core@5.96.2)(@tanstack/router-core@1.168.14) + '@tanstack/router-ssr-query-core': 1.167.1(@tanstack/query-core@5.99.0)(@tanstack/router-core@1.168.14) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: @@ -11382,7 +11473,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@tanstack/react-start-rsc@0.0.10(@vitejs/plugin-rsc@0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/react-start-rsc@0.0.10(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@tanstack/react-router': 1.168.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-server': 1.166.35(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -11397,7 +11488,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@vitejs/plugin-rsc': 0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitejs/plugin-rsc': 0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -11418,11 +11509,11 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.167.30(@vitejs/plugin-rsc@0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/react-start@1.167.30(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@tanstack/react-router': 1.168.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-client': 1.166.34(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-rsc': 0.0.10(@vitejs/plugin-rsc@0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/react-start-rsc': 0.0.10(@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/react-start-server': 1.166.35(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/router-utils': 1.161.6 '@tanstack/start-client-core': 1.167.15 @@ -11433,7 +11524,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3) optionalDependencies: - '@vitejs/plugin-rsc': 0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitejs/plugin-rsc': 0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -11457,16 +11548,16 @@ snapshots: '@tanstack/router-core@1.168.13': dependencies: '@tanstack/history': 1.161.6 - cookie-es: 2.0.0 - seroval: 1.5.1 - seroval-plugins: 1.5.1(seroval@1.5.1) + cookie-es: 2.0.1 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) '@tanstack/router-core@1.168.14': dependencies: '@tanstack/history': 1.161.6 cookie-es: 3.1.1 - seroval: 1.5.1 - seroval-plugins: 1.5.1(seroval@1.5.1) + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.168.14)(csstype@3.2.3)': dependencies: @@ -11510,9 +11601,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-ssr-query-core@1.167.1(@tanstack/query-core@5.96.2)(@tanstack/router-core@1.168.14)': + '@tanstack/router-ssr-query-core@1.167.1(@tanstack/query-core@5.99.0)(@tanstack/router-core@1.168.14)': dependencies: - '@tanstack/query-core': 5.96.2 + '@tanstack/query-core': 5.99.0 '@tanstack/router-core': 1.168.14 '@tanstack/router-utils@1.161.6': @@ -11534,7 +11625,7 @@ snapshots: '@tanstack/router-core': 1.168.13 '@tanstack/start-fn-stubs': 1.161.6 '@tanstack/start-storage-context': 1.166.27 - seroval: 1.5.1 + seroval: 1.5.2 '@tanstack/start-fn-stubs@1.161.6': {} @@ -11554,7 +11645,7 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 picomatch: 4.0.4 - seroval: 1.5.1 + seroval: 1.5.2 source-map: 0.7.6 srvx: 0.11.15 tinyglobby: 0.2.15 @@ -11578,7 +11669,7 @@ snapshots: '@tanstack/start-client-core': 1.167.15 '@tanstack/start-storage-context': 1.166.27 h3-v2: h3@2.0.1-rc.16 - seroval: 1.5.1 + seroval: 1.5.2 transitivePeerDependencies: - crossws @@ -11756,7 +11847,7 @@ snapshots: dependencies: '@types/node': 25.5.0 - '@types/node@22.19.15': + '@types/node@22.19.17': dependencies: undici-types: 6.21.0 optional: true @@ -11828,8 +11919,7 @@ snapshots: '@types/triple-beam@1.3.5': {} - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} '@types/unist@2.0.11': {} @@ -11947,9 +12037,9 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitejs/plugin-rsc@0.5.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-rsc@0.5.24(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@rolldown/pluginutils': 1.0.0-rc.13 + '@rolldown/pluginutils': 1.0.0-rc.15 es-module-lexer: 2.0.0 estree-walker: 3.0.3 magic-string: 0.30.21 @@ -12026,6 +12116,8 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 + '@xstate/fsm@2.0.0': {} + '@xstate/react@6.1.0(@types/react@19.2.14)(react@19.2.3)(xstate@5.30.0)': dependencies: react: 19.2.3 @@ -12177,6 +12269,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} async-sema@3.1.1: {} @@ -12202,7 +12300,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 cosmiconfig: 7.1.0 - resolve: 1.22.11 + resolve: 1.22.12 optional: true bail@2.0.2: {} @@ -12547,7 +12645,7 @@ snapshots: cookie-es@1.2.2: {} - cookie-es@2.0.0: {} + cookie-es@2.0.1: {} cookie-es@3.1.1: {} @@ -13633,6 +13731,8 @@ snapshots: graceful-fs@4.2.11: {} + graphql@16.13.2: {} + gray-matter@4.0.3: dependencies: js-yaml: 3.14.2 @@ -14202,6 +14302,8 @@ snapshots: dependencies: jpeg-js: 0.4.4 + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -14387,6 +14489,22 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 + lit-element@4.2.2: + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@lit/reactive-element': 2.1.2 + lit-html: 3.3.2 + + lit-html@3.3.2: + dependencies: + '@types/trusted-types': 2.0.7 + + lit@3.3.2: + dependencies: + '@lit/reactive-element': 2.1.2 + lit-element: 4.2.2 + lit-html: 3.3.2 + load-json-file@4.0.0: dependencies: graceful-fs: 4.2.11 @@ -15659,6 +15777,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + regexparam@2.0.2: {} + rehype-autolink-headings@7.1.0: dependencies: '@types/hast': 3.0.4 @@ -15752,7 +15872,7 @@ snapshots: dependencies: debug: 4.4.3 module-details-from-path: 1.0.4 - resolve: 1.22.11 + resolve: 1.22.12 transitivePeerDependencies: - supports-color @@ -15783,6 +15903,13 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + resolve@2.0.0-next.6: dependencies: es-errors: 1.3.0 @@ -15946,11 +16073,11 @@ snapshots: serialize-javascript@7.0.5: {} - seroval-plugins@1.5.1(seroval@1.5.1): + seroval-plugins@1.5.2(seroval@1.5.2): dependencies: - seroval: 1.5.1 + seroval: 1.5.2 - seroval@1.5.1: {} + seroval@1.5.2: {} serve-static@2.2.1: dependencies: @@ -16072,8 +16199,8 @@ snapshots: solid-js@1.9.12: dependencies: csstype: 3.2.3 - seroval: 1.5.1 - seroval-plugins: 1.5.1(seroval@1.5.1) + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) source-map-explorer@2.5.3: dependencies: @@ -16771,6 +16898,10 @@ snapshots: triple-beam: 1.4.1 winston-transport: 4.9.0 + worktop@0.7.3: + dependencies: + regexparam: 2.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index fdcb3da25..d0f8a9c2d 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -8,6 +8,7 @@ const LazyNavbarAuthControls = React.lazy(() => default: m.NavbarAuthControls, })), ) +import { NavbarCartButton } from './NavbarCartButton' import { Link, useLocation, useMatches } from '@tanstack/react-router' import { NetlifyImage } from './NetlifyImage' import { @@ -303,6 +304,7 @@ export function Navbar({ children }: { children: React.ReactNode }) { +
{canLoadAuthControls ? ( diff --git a/src/components/NavbarCartButton.tsx b/src/components/NavbarCartButton.tsx new file mode 100644 index 000000000..9dfd9de58 --- /dev/null +++ b/src/components/NavbarCartButton.tsx @@ -0,0 +1,46 @@ +import { Link, useLocation } from '@tanstack/react-router' +import { ShoppingCart } from 'lucide-react' +import { twMerge } from 'tailwind-merge' +import { useCart } from '~/hooks/useCart' + +/** + * Cart button in the main Navbar. + * + * Visibility rules: + * • Always visible on /shop/* routes (even at zero items) + * • Site-wide when the cart has at least one item + * • Hidden elsewhere when the cart is empty + */ +export function NavbarCartButton() { + const { pathname } = useLocation() + const { totalQuantity } = useCart() + const onShopRoute = pathname === '/shop' || pathname.startsWith('/shop/') + + if (!onShopRoute && totalQuantity === 0) return null + + return ( + 0 ? `Cart (${totalQuantity} items)` : 'Cart'} + className={twMerge( + 'relative flex items-center justify-center', + 'h-9 w-9 rounded-lg transition-colors', + 'hover:bg-gray-500/10 text-gray-700 dark:text-gray-300', + )} + > + + {totalQuantity > 0 ? ( + + {totalQuantity > 99 ? '99+' : totalQuantity} + + ) : null} + + ) +} diff --git a/src/components/RedirectVersionBanner.tsx b/src/components/RedirectVersionBanner.tsx index 6ca077dbe..eb06fe9af 100644 --- a/src/components/RedirectVersionBanner.tsx +++ b/src/components/RedirectVersionBanner.tsx @@ -29,7 +29,7 @@ export function RedirectVersionBanner(props: {

You are currently reading {version} docs. Redirect to{' '} @@ -40,7 +40,7 @@ export function RedirectVersionBanner(props: {

+ + {/* Mobile backdrop */} + {isMobileOpen ? ( + +
+ + setIsMobileOpen(false)} + /> + + {/* Desktop collapse toggle pinned to bottom */} +
+ +
+ + + {/* + * Main content — padded left by the sidebar's collapsed/expanded width + * so it never sits beneath the fixed rail. On mobile the sidebar is a + * slide-in drawer, so no padding is reserved there. + */} +
+ {children} +
+
+ ) +} + +function ShopSidebarNav({ + collections, + showLabels, + onNavigate, +}: { + collections: Array + showLabels: boolean + onNavigate: () => void +}) { + return ( + + ) +} + +function SidebarSection({ + label, + showLabels, + children, +}: { + label: string + showLabels: boolean + children: React.ReactNode +}) { + return ( +
+
+ {label} +
+ {children} +
+ ) +} + +type IconComponent = React.ComponentType<{ className?: string }> + +function SidebarLink({ + to, + params, + label, + icon: Icon, + showLabels, + onNavigate, + exact, +}: { + to: string + params?: Record + label: string + icon: IconComponent + showLabels: boolean + onNavigate: () => void + exact?: boolean +}) { + const { pathname } = useLocation() + // Resolve a simple active check without depending on to-typing details + const resolvedHref = params + ? to.replace(/\$(\w+)/g, (_, k: string) => params[k] ?? '') + : to + const isActive = exact + ? pathname === resolvedHref + : pathname === resolvedHref || pathname.startsWith(`${resolvedHref}/`) + + return ( + + + + {label} + + + ) +} diff --git a/src/hooks/useCart.ts b/src/hooks/useCart.ts new file mode 100644 index 000000000..4c71497c1 --- /dev/null +++ b/src/hooks/useCart.ts @@ -0,0 +1,158 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + addToCart, + getCart, + removeCartLine, + updateCartLine, +} from '~/utils/shop.functions' +import type { CartDetail } from '~/utils/shopify-queries' + +/** + * Shared React Query key for the current user's cart. + * + * The cart ID lives in an httpOnly cookie on the server, so the client never + * needs to know it — a single cache key is enough. Route loaders prefetch + * into this key so the first render already has the data. + */ +export const CART_QUERY_KEY = ['shopify', 'cart'] as const + +/** + * Read the current cart. Data is loader-seeded on shop routes, so there is + * no hydration gap — components that call this render with real data on the + * first frame. On non-shop routes the hook falls back to fetching on mount. + */ +export function useCart() { + const query = useQuery({ + queryKey: CART_QUERY_KEY, + queryFn: () => getCart(), + staleTime: 30_000, + }) + + return { + cart: query.data ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError, + error: query.error, + refetch: query.refetch, + totalQuantity: query.data?.totalQuantity ?? 0, + } +} + +export function useAddToCart() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: (input: { variantId: string; quantity?: number }) => + addToCart({ + data: { variantId: input.variantId, quantity: input.quantity ?? 1 }, + }), + + // Bump totalQuantity immediately so the navbar badge moves in the same + // frame the user clicks. We can't optimistically render new line items + // without the full product snapshot, which callers don't have here — + // the refetch on settle fills that in. + onMutate: async (input) => { + const quantity = input.quantity ?? 1 + await qc.cancelQueries({ queryKey: CART_QUERY_KEY }) + const previous = qc.getQueryData(CART_QUERY_KEY) + if (previous) { + qc.setQueryData(CART_QUERY_KEY, { + ...previous, + totalQuantity: (previous.totalQuantity ?? 0) + quantity, + }) + } + return { previous } + }, + + onError: (_err, _input, ctx) => { + if (ctx?.previous !== undefined) + qc.setQueryData(CART_QUERY_KEY, ctx.previous) + }, + + onSuccess: (cart) => { + qc.setQueryData(CART_QUERY_KEY, cart) + }, + + onSettled: () => { + qc.invalidateQueries({ queryKey: CART_QUERY_KEY }) + }, + }) +} + +export function useUpdateCartLine() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (input: { lineId: string; quantity: number }) => + updateCartLine({ data: input }), + + onMutate: async (input) => { + await qc.cancelQueries({ queryKey: CART_QUERY_KEY }) + const previous = qc.getQueryData(CART_QUERY_KEY) + if (previous) { + const nextLines = previous.lines.nodes.map((line) => + line.id === input.lineId + ? { ...line, quantity: input.quantity } + : line, + ) + const nextQty = nextLines.reduce((sum, line) => sum + line.quantity, 0) + qc.setQueryData(CART_QUERY_KEY, { + ...previous, + totalQuantity: nextQty, + lines: { ...previous.lines, nodes: nextLines }, + }) + } + return { previous } + }, + + onError: (_err, _input, ctx) => { + if (ctx?.previous !== undefined) + qc.setQueryData(CART_QUERY_KEY, ctx.previous) + }, + + onSuccess: (cart) => { + qc.setQueryData(CART_QUERY_KEY, cart) + }, + + onSettled: () => { + qc.invalidateQueries({ queryKey: CART_QUERY_KEY }) + }, + }) +} + +export function useRemoveCartLine() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (input: { lineId: string }) => removeCartLine({ data: input }), + + onMutate: async (input) => { + await qc.cancelQueries({ queryKey: CART_QUERY_KEY }) + const previous = qc.getQueryData(CART_QUERY_KEY) + if (previous) { + const nextLines = previous.lines.nodes.filter( + (line) => line.id !== input.lineId, + ) + const nextQty = nextLines.reduce((sum, line) => sum + line.quantity, 0) + qc.setQueryData(CART_QUERY_KEY, { + ...previous, + totalQuantity: nextQty, + lines: { ...previous.lines, nodes: nextLines }, + }) + } + return { previous } + }, + + onError: (_err, _input, ctx) => { + if (ctx?.previous !== undefined) + qc.setQueryData(CART_QUERY_KEY, ctx.previous) + }, + + onSuccess: (cart) => { + qc.setQueryData(CART_QUERY_KEY, cart) + }, + + onSettled: () => { + qc.invalidateQueries({ queryKey: CART_QUERY_KEY }) + }, + }) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5ea754c65..be592601e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as TenetsRouteImport } from './routes/tenets' import { Route as SupportRouteImport } from './routes/support' import { Route as SponsorsEmbedRouteImport } from './routes/sponsors-embed' import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' +import { Route as ShopRouteImport } from './routes/shop' import { Route as RssDotxmlRouteImport } from './routes/rss[.]xml' import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as PrivacyRouteImport } from './routes/privacy' @@ -41,6 +42,7 @@ import { Route as LibraryIdRouteRouteImport } from './routes/$libraryId/route' import { Route as IndexRouteImport } from './routes/index' import { Route as StatsIndexRouteImport } from './routes/stats/index' import { Route as ShowcaseIndexRouteImport } from './routes/showcase/index' +import { Route as ShopIndexRouteImport } from './routes/shop.index' import { Route as PartnersIndexRouteImport } from './routes/partners.index' import { Route as BuilderIndexRouteImport } from './routes/builder.index' import { Route as BlogIndexRouteImport } from './routes/blog.index' @@ -49,6 +51,7 @@ import { Route as AccountIndexRouteImport } from './routes/account/index' import { Route as LibraryIdIndexRouteImport } from './routes/$libraryId/index' import { Route as ShowcaseSubmitRouteImport } from './routes/showcase/submit' import { Route as ShowcaseIdRouteImport } from './routes/showcase/$id' +import { Route as ShopCartRouteImport } from './routes/shop.cart' import { Route as PartnersPartnerRouteImport } from './routes/partners.$partner' import { Route as OauthTokenRouteImport } from './routes/oauth/token' import { Route as OauthRegisterRouteImport } from './routes/oauth/register' @@ -98,6 +101,7 @@ import { Route as AdminFeedbackIndexRouteImport } from './routes/admin/feedback. import { Route as LibraryIdVersionIndexRouteImport } from './routes/$libraryId/$version.index' import { Route as StatsNpmPackagesRouteImport } from './routes/stats/npm/$packages' import { Route as ShowcaseEditIdRouteImport } from './routes/showcase/edit.$id' +import { Route as ShopProductsHandleRouteImport } from './routes/shop.products.$handle' import { Route as IntentRegistryPackageNameRouteImport } from './routes/intent/registry/$packageName' import { Route as AuthProviderStartRouteImport } from './routes/auth/$provider/start' import { Route as ApiMcpSplatRouteImport } from './routes/api/mcp/$' @@ -172,6 +176,11 @@ const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({ path: '/sitemap.xml', getParentRoute: () => rootRouteImport, } as any) +const ShopRoute = ShopRouteImport.update({ + id: '/shop', + path: '/shop', + getParentRoute: () => rootRouteImport, +} as any) const RssDotxmlRoute = RssDotxmlRouteImport.update({ id: '/rss.xml', path: '/rss.xml', @@ -302,6 +311,11 @@ const ShowcaseIndexRoute = ShowcaseIndexRouteImport.update({ path: '/showcase/', getParentRoute: () => rootRouteImport, } as any) +const ShopIndexRoute = ShopIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ShopRoute, +} as any) const PartnersIndexRoute = PartnersIndexRouteImport.update({ id: '/', path: '/', @@ -342,6 +356,11 @@ const ShowcaseIdRoute = ShowcaseIdRouteImport.update({ path: '/showcase/$id', getParentRoute: () => rootRouteImport, } as any) +const ShopCartRoute = ShopCartRouteImport.update({ + id: '/cart', + path: '/cart', + getParentRoute: () => ShopRoute, +} as any) const PartnersPartnerRoute = PartnersPartnerRouteImport.update({ id: '/$partner', path: '/$partner', @@ -588,6 +607,11 @@ const ShowcaseEditIdRoute = ShowcaseEditIdRouteImport.update({ path: '/showcase/edit/$id', getParentRoute: () => rootRouteImport, } as any) +const ShopProductsHandleRoute = ShopProductsHandleRouteImport.update({ + id: '/products/$handle', + path: '/products/$handle', + getParentRoute: () => ShopRoute, +} as any) const IntentRegistryPackageNameRoute = IntentRegistryPackageNameRouteImport.update({ id: '/intent/registry/$packageName', @@ -851,6 +875,7 @@ export interface FileRoutesByFullPath { '/privacy': typeof PrivacyRoute '/robots.txt': typeof RobotsDottxtRoute '/rss.xml': typeof RssDotxmlRoute + '/shop': typeof ShopRouteWithChildren '/sitemap.xml': typeof SitemapDotxmlRoute '/sponsors-embed': typeof SponsorsEmbedRoute '/support': typeof SupportRoute @@ -880,6 +905,7 @@ export interface FileRoutesByFullPath { '/oauth/register': typeof OauthRegisterRoute '/oauth/token': typeof OauthTokenRoute '/partners/$partner': typeof PartnersPartnerRoute + '/shop/cart': typeof ShopCartRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute '/$libraryId/': typeof LibraryIdIndexRoute @@ -888,6 +914,7 @@ export interface FileRoutesByFullPath { '/blog/': typeof BlogIndexRoute '/builder/': typeof BuilderIndexRoute '/partners/': typeof PartnersIndexRoute + '/shop/': typeof ShopIndexRoute '/showcase/': typeof ShowcaseIndexRoute '/stats/': typeof StatsIndexRoute '/$libraryId/$version/docs': typeof LibraryIdVersionDocsRouteWithChildren @@ -914,6 +941,7 @@ export interface FileRoutesByFullPath { '/api/mcp/$': typeof ApiMcpSplatRoute '/auth/$provider/start': typeof AuthProviderStartRoute '/intent/registry/$packageName': typeof IntentRegistryPackageNameRouteWithChildren + '/shop/products/$handle': typeof ShopProductsHandleRoute '/showcase/edit/$id': typeof ShowcaseEditIdRoute '/stats/npm/$packages': typeof StatsNpmPackagesRoute '/$libraryId/$version/': typeof LibraryIdVersionIndexRoute @@ -1007,6 +1035,7 @@ export interface FileRoutesByTo { '/oauth/register': typeof OauthRegisterRoute '/oauth/token': typeof OauthTokenRoute '/partners/$partner': typeof PartnersPartnerRoute + '/shop/cart': typeof ShopCartRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute '/$libraryId': typeof LibraryIdIndexRoute @@ -1015,6 +1044,7 @@ export interface FileRoutesByTo { '/blog': typeof BlogIndexRoute '/builder': typeof BuilderIndexRoute '/partners': typeof PartnersIndexRoute + '/shop': typeof ShopIndexRoute '/showcase': typeof ShowcaseIndexRoute '/stats': typeof StatsIndexRoute '/admin/feedback/$id': typeof AdminFeedbackIdRoute @@ -1039,6 +1069,7 @@ export interface FileRoutesByTo { '/api/github/webhook': typeof ApiGithubWebhookRoute '/api/mcp/$': typeof ApiMcpSplatRoute '/auth/$provider/start': typeof AuthProviderStartRoute + '/shop/products/$handle': typeof ShopProductsHandleRoute '/showcase/edit/$id': typeof ShowcaseEditIdRoute '/stats/npm/$packages': typeof StatsNpmPackagesRoute '/$libraryId/$version': typeof LibraryIdVersionIndexRoute @@ -1111,6 +1142,7 @@ export interface FileRoutesById { '/privacy': typeof PrivacyRoute '/robots.txt': typeof RobotsDottxtRoute '/rss.xml': typeof RssDotxmlRoute + '/shop': typeof ShopRouteWithChildren '/sitemap.xml': typeof SitemapDotxmlRoute '/sponsors-embed': typeof SponsorsEmbedRoute '/support': typeof SupportRoute @@ -1140,6 +1172,7 @@ export interface FileRoutesById { '/oauth/register': typeof OauthRegisterRoute '/oauth/token': typeof OauthTokenRoute '/partners/$partner': typeof PartnersPartnerRoute + '/shop/cart': typeof ShopCartRoute '/showcase/$id': typeof ShowcaseIdRoute '/showcase/submit': typeof ShowcaseSubmitRoute '/$libraryId/': typeof LibraryIdIndexRoute @@ -1148,6 +1181,7 @@ export interface FileRoutesById { '/blog/': typeof BlogIndexRoute '/builder/': typeof BuilderIndexRoute '/partners/': typeof PartnersIndexRoute + '/shop/': typeof ShopIndexRoute '/showcase/': typeof ShowcaseIndexRoute '/stats/': typeof StatsIndexRoute '/$libraryId/$version/docs': typeof LibraryIdVersionDocsRouteWithChildren @@ -1174,6 +1208,7 @@ export interface FileRoutesById { '/api/mcp/$': typeof ApiMcpSplatRoute '/auth/$provider/start': typeof AuthProviderStartRoute '/intent/registry/$packageName': typeof IntentRegistryPackageNameRouteWithChildren + '/shop/products/$handle': typeof ShopProductsHandleRoute '/showcase/edit/$id': typeof ShowcaseEditIdRoute '/stats/npm/$packages': typeof StatsNpmPackagesRoute '/$libraryId/$version/': typeof LibraryIdVersionIndexRoute @@ -1247,6 +1282,7 @@ export interface FileRouteTypes { | '/privacy' | '/robots.txt' | '/rss.xml' + | '/shop' | '/sitemap.xml' | '/sponsors-embed' | '/support' @@ -1276,6 +1312,7 @@ export interface FileRouteTypes { | '/oauth/register' | '/oauth/token' | '/partners/$partner' + | '/shop/cart' | '/showcase/$id' | '/showcase/submit' | '/$libraryId/' @@ -1284,6 +1321,7 @@ export interface FileRouteTypes { | '/blog/' | '/builder/' | '/partners/' + | '/shop/' | '/showcase/' | '/stats/' | '/$libraryId/$version/docs' @@ -1310,6 +1348,7 @@ export interface FileRouteTypes { | '/api/mcp/$' | '/auth/$provider/start' | '/intent/registry/$packageName' + | '/shop/products/$handle' | '/showcase/edit/$id' | '/stats/npm/$packages' | '/$libraryId/$version/' @@ -1403,6 +1442,7 @@ export interface FileRouteTypes { | '/oauth/register' | '/oauth/token' | '/partners/$partner' + | '/shop/cart' | '/showcase/$id' | '/showcase/submit' | '/$libraryId' @@ -1411,6 +1451,7 @@ export interface FileRouteTypes { | '/blog' | '/builder' | '/partners' + | '/shop' | '/showcase' | '/stats' | '/admin/feedback/$id' @@ -1435,6 +1476,7 @@ export interface FileRouteTypes { | '/api/github/webhook' | '/api/mcp/$' | '/auth/$provider/start' + | '/shop/products/$handle' | '/showcase/edit/$id' | '/stats/npm/$packages' | '/$libraryId/$version' @@ -1506,6 +1548,7 @@ export interface FileRouteTypes { | '/privacy' | '/robots.txt' | '/rss.xml' + | '/shop' | '/sitemap.xml' | '/sponsors-embed' | '/support' @@ -1535,6 +1578,7 @@ export interface FileRouteTypes { | '/oauth/register' | '/oauth/token' | '/partners/$partner' + | '/shop/cart' | '/showcase/$id' | '/showcase/submit' | '/$libraryId/' @@ -1543,6 +1587,7 @@ export interface FileRouteTypes { | '/blog/' | '/builder/' | '/partners/' + | '/shop/' | '/showcase/' | '/stats/' | '/$libraryId/$version/docs' @@ -1569,6 +1614,7 @@ export interface FileRouteTypes { | '/api/mcp/$' | '/auth/$provider/start' | '/intent/registry/$packageName' + | '/shop/products/$handle' | '/showcase/edit/$id' | '/stats/npm/$packages' | '/$libraryId/$version/' @@ -1641,6 +1687,7 @@ export interface RootRouteChildren { PrivacyRoute: typeof PrivacyRoute RobotsDottxtRoute: typeof RobotsDottxtRoute RssDotxmlRoute: typeof RssDotxmlRoute + ShopRoute: typeof ShopRouteWithChildren SitemapDotxmlRoute: typeof SitemapDotxmlRoute SponsorsEmbedRoute: typeof SponsorsEmbedRoute SupportRoute: typeof SupportRoute @@ -1750,6 +1797,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SitemapDotxmlRouteImport parentRoute: typeof rootRouteImport } + '/shop': { + id: '/shop' + path: '/shop' + fullPath: '/shop' + preLoaderRoute: typeof ShopRouteImport + parentRoute: typeof rootRouteImport + } '/rss.xml': { id: '/rss.xml' path: '/rss.xml' @@ -1932,6 +1986,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShowcaseIndexRouteImport parentRoute: typeof rootRouteImport } + '/shop/': { + id: '/shop/' + path: '/' + fullPath: '/shop/' + preLoaderRoute: typeof ShopIndexRouteImport + parentRoute: typeof ShopRoute + } '/partners/': { id: '/partners/' path: '/' @@ -1988,6 +2049,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShowcaseIdRouteImport parentRoute: typeof rootRouteImport } + '/shop/cart': { + id: '/shop/cart' + path: '/cart' + fullPath: '/shop/cart' + preLoaderRoute: typeof ShopCartRouteImport + parentRoute: typeof ShopRoute + } '/partners/$partner': { id: '/partners/$partner' path: '/$partner' @@ -2331,6 +2399,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShowcaseEditIdRouteImport parentRoute: typeof rootRouteImport } + '/shop/products/$handle': { + id: '/shop/products/$handle' + path: '/products/$handle' + fullPath: '/shop/products/$handle' + preLoaderRoute: typeof ShopProductsHandleRouteImport + parentRoute: typeof ShopRoute + } '/intent/registry/$packageName': { id: '/intent/registry/$packageName' path: '/intent/registry/$packageName' @@ -2810,6 +2885,20 @@ const PartnersRouteWithChildren = PartnersRoute._addFileChildren( PartnersRouteChildren, ) +interface ShopRouteChildren { + ShopCartRoute: typeof ShopCartRoute + ShopIndexRoute: typeof ShopIndexRoute + ShopProductsHandleRoute: typeof ShopProductsHandleRoute +} + +const ShopRouteChildren: ShopRouteChildren = { + ShopCartRoute: ShopCartRoute, + ShopIndexRoute: ShopIndexRoute, + ShopProductsHandleRoute: ShopProductsHandleRoute, +} + +const ShopRouteWithChildren = ShopRoute._addFileChildren(ShopRouteChildren) + interface IntentRegistryPackageNameRouteChildren { IntentRegistryPackageNameSkillNameRoute: typeof IntentRegistryPackageNameSkillNameRoute IntentRegistryPackageNameChar123Char125DotmdRoute: typeof IntentRegistryPackageNameChar123Char125DotmdRoute @@ -2855,6 +2944,7 @@ const rootRouteChildren: RootRouteChildren = { PrivacyRoute: PrivacyRoute, RobotsDottxtRoute: RobotsDottxtRoute, RssDotxmlRoute: RssDotxmlRoute, + ShopRoute: ShopRouteWithChildren, SitemapDotxmlRoute: SitemapDotxmlRoute, SponsorsEmbedRoute: SponsorsEmbedRoute, SupportRoute: SupportRoute, diff --git a/src/routes/ai.$version.index.tsx b/src/routes/ai.$version.index.tsx index c15abdf0c..d8be506a7 100644 --- a/src/routes/ai.$version.index.tsx +++ b/src/routes/ai.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import AiLanding from '~/components/landing/AiLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/ai/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'ai', AiLanding), +export const Route = createFileRoute('/ai/$version/')( + createLibraryLandingPage('/ai/$version/', 'ai', AiLanding), ) diff --git a/src/routes/api/builder/deploy/check-name.ts b/src/routes/api/builder/deploy/check-name.ts index 2667aa371..a3ada3e7a 100644 --- a/src/routes/api/builder/deploy/check-name.ts +++ b/src/routes/api/builder/deploy/check-name.ts @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute("/api/builder/deploy/check-name" as any)({ +export const Route = createFileRoute("/api/builder/deploy/check-name")({ server: { handlers: { GET: async ({ request }: { request: Request }) => { diff --git a/src/routes/cli.$version.index.tsx b/src/routes/cli.$version.index.tsx index f925441b5..36e9d9feb 100644 --- a/src/routes/cli.$version.index.tsx +++ b/src/routes/cli.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import CliLanding from '~/components/landing/CliLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/cli/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'cli', CliLanding), +export const Route = createFileRoute('/cli/$version/')( + createLibraryLandingPage('/cli/$version/', 'cli', CliLanding), ) diff --git a/src/routes/config.$version.index.tsx b/src/routes/config.$version.index.tsx index 1dcbf8788..c0903d4d3 100644 --- a/src/routes/config.$version.index.tsx +++ b/src/routes/config.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import ConfigLanding from '~/components/landing/ConfigLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/config/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'config', ConfigLanding), +export const Route = createFileRoute('/config/$version/')( + createLibraryLandingPage('/config/$version/', 'config', ConfigLanding), ) diff --git a/src/routes/db.$version.index.tsx b/src/routes/db.$version.index.tsx index 6b738bc0f..50462ba0a 100644 --- a/src/routes/db.$version.index.tsx +++ b/src/routes/db.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import DbLanding from '~/components/landing/DbLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/db/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'db', DbLanding), +export const Route = createFileRoute('/db/$version/')( + createLibraryLandingPage('/db/$version/', 'db', DbLanding), ) diff --git a/src/routes/devtools.$version.index.tsx b/src/routes/devtools.$version.index.tsx index e943d354a..426e2de76 100644 --- a/src/routes/devtools.$version.index.tsx +++ b/src/routes/devtools.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import DevtoolsLanding from '~/components/landing/DevtoolsLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/devtools/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'devtools', DevtoolsLanding), +export const Route = createFileRoute('/devtools/$version/')( + createLibraryLandingPage('/devtools/$version/', 'devtools', DevtoolsLanding), ) diff --git a/src/routes/form.$version.index.tsx b/src/routes/form.$version.index.tsx index aeaddbfdd..4c9eeb511 100644 --- a/src/routes/form.$version.index.tsx +++ b/src/routes/form.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import FormLanding from '~/components/landing/FormLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/form/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'form', FormLanding), +export const Route = createFileRoute('/form/$version/')( + createLibraryLandingPage('/form/$version/', 'form', FormLanding), ) diff --git a/src/routes/hotkeys.$version.index.tsx b/src/routes/hotkeys.$version.index.tsx index b6b0a81bf..fc7f73650 100644 --- a/src/routes/hotkeys.$version.index.tsx +++ b/src/routes/hotkeys.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import HotkeysLanding from '~/components/landing/HotkeysLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/hotkeys/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'hotkeys', HotkeysLanding), +export const Route = createFileRoute('/hotkeys/$version/')( + createLibraryLandingPage('/hotkeys/$version/', 'hotkeys', HotkeysLanding), ) diff --git a/src/routes/intent.$version.index.tsx b/src/routes/intent.$version.index.tsx index c472cfc8b..556f230e6 100644 --- a/src/routes/intent.$version.index.tsx +++ b/src/routes/intent.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import IntentLanding from '~/components/landing/IntentLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/intent/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'intent', IntentLanding), +export const Route = createFileRoute('/intent/$version/')( + createLibraryLandingPage('/intent/$version/', 'intent', IntentLanding), ) diff --git a/src/routes/pacer.$version.index.tsx b/src/routes/pacer.$version.index.tsx index c256e01e9..9e01b25c6 100644 --- a/src/routes/pacer.$version.index.tsx +++ b/src/routes/pacer.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import PacerLanding from '~/components/landing/PacerLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/pacer/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'pacer', PacerLanding), +export const Route = createFileRoute('/pacer/$version/')( + createLibraryLandingPage('/pacer/$version/', 'pacer', PacerLanding), ) diff --git a/src/routes/query.$version.index.tsx b/src/routes/query.$version.index.tsx index 0a85cf893..d15de6706 100644 --- a/src/routes/query.$version.index.tsx +++ b/src/routes/query.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import QueryLanding from '~/components/landing/QueryLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/query/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'query', QueryLanding), +export const Route = createFileRoute('/query/$version/')( + createLibraryLandingPage('/query/$version/', 'query', QueryLanding), ) diff --git a/src/routes/ranger.$version.index.tsx b/src/routes/ranger.$version.index.tsx index 34df298b4..17bc06430 100644 --- a/src/routes/ranger.$version.index.tsx +++ b/src/routes/ranger.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import RangerLanding from '~/components/landing/RangerLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/ranger/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'ranger', RangerLanding), +export const Route = createFileRoute('/ranger/$version/')( + createLibraryLandingPage('/ranger/$version/', 'ranger', RangerLanding), ) diff --git a/src/routes/router.$version.index.tsx b/src/routes/router.$version.index.tsx index 87026605f..1a3f4f77f 100644 --- a/src/routes/router.$version.index.tsx +++ b/src/routes/router.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import RouterLanding from '~/components/landing/RouterLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/router/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'router', RouterLanding), +export const Route = createFileRoute('/router/$version/')( + createLibraryLandingPage('/router/$version/', 'router', RouterLanding), ) diff --git a/src/routes/shop.cart.tsx b/src/routes/shop.cart.tsx new file mode 100644 index 000000000..17c4308a3 --- /dev/null +++ b/src/routes/shop.cart.tsx @@ -0,0 +1,212 @@ +import { Link, createFileRoute } from '@tanstack/react-router' +import { Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react' +import { twMerge } from 'tailwind-merge' +import { useCart, useRemoveCartLine, useUpdateCartLine } from '~/hooks/useCart' +import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' +import type { CartLineDetail } from '~/utils/shopify-queries' + +export const Route = createFileRoute('/shop/cart')({ + component: CartPage, +}) + +function CartPage() { + const { cart } = useCart() + + // The parent /shop loader prefetches the cart into the React Query cache, + // so this always renders with real data on the first frame. + if (!cart || cart.lines.nodes.length === 0) return + + const { checkoutUrl, cost, lines, totalQuantity } = cart + const subtotal = cost.subtotalAmount + const total = cost.totalAmount + + return ( +
+
+

Cart

+

+ {totalQuantity} {totalQuantity === 1 ? 'item' : 'items'} +

+
+ +
+
    + {lines.nodes.map((line) => ( + + ))} +
+ + +
+
+ ) +} + +function EmptyCart() { + return ( +
+
+

Cart

+
+
+ +

+ Your cart is empty. +

+ + Shop all products + +
+
+ ) +} + +function CartLineRow({ line }: { line: CartLineDetail }) { + const update = useUpdateCartLine() + const remove = useRemoveCartLine() + const { merchandise } = line + const options = merchandise.selectedOptions + .filter((o) => o.name.toLowerCase() !== 'title') + .map((o) => `${o.name}: ${o.value}`) + .join(' · ') + + const isBusy = update.isPending || remove.isPending + + return ( +
  • + +
    + {merchandise.image ? ( + {merchandise.image.altText + ) : null} +
    + + +
    +
    +
    + + {merchandise.product.title} + + {options ? ( +

    + {options} +

    + ) : null} +
    +
    + {formatMoney( + line.cost.totalAmount.amount, + line.cost.totalAmount.currencyCode, + )} +
    +
    + +
    + { + if (next <= 0) { + remove.mutate({ lineId: line.id }) + } else { + update.mutate({ lineId: line.id, quantity: next }) + } + }} + disabled={isBusy} + /> + +
    +
    +
  • + ) +} + +function QuantityStepper({ + quantity, + onChange, + disabled, +}: { + quantity: number + onChange: (next: number) => void + disabled?: boolean +}) { + return ( +
    + + {quantity} + +
    + ) +} diff --git a/src/routes/shop.index.tsx b/src/routes/shop.index.tsx new file mode 100644 index 000000000..b563aaee0 --- /dev/null +++ b/src/routes/shop.index.tsx @@ -0,0 +1,69 @@ +import { Link, createFileRoute } from '@tanstack/react-router' +import { getProducts } from '~/utils/shop.functions' +import type { ProductListItem } from '~/utils/shopify-queries' +import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' + +export const Route = createFileRoute('/shop/')({ + loader: async () => { + const products = await getProducts() + return { products } + }, + component: ShopIndex, +}) + +function ShopIndex() { + const { products } = Route.useLoaderData() + return ( +
    +
    +

    All Products

    +

    + {products.length} {products.length === 1 ? 'product' : 'products'} +

    +
    + + {products.length === 0 ? ( +
    + No products yet. Check back soon! +
    + ) : ( +
    + {products.map((product) => ( + + ))} +
    + )} +
    + ) +} + +function ProductCard({ product }: { product: ProductListItem }) { + const price = product.priceRange.minVariantPrice + const image = product.featuredImage + return ( + +
    + {image ? ( + {image.altText + ) : null} +
    +
    +

    {product.title}

    +

    + {formatMoney(price.amount, price.currencyCode)} +

    +
    + + ) +} diff --git a/src/routes/shop.products.$handle.tsx b/src/routes/shop.products.$handle.tsx new file mode 100644 index 000000000..a579a4331 --- /dev/null +++ b/src/routes/shop.products.$handle.tsx @@ -0,0 +1,185 @@ +import * as React from 'react' +import { createFileRoute, notFound } from '@tanstack/react-router' +import { getProduct } from '~/utils/shop.functions' +import type { + ProductDetail, + ProductDetailVariant, +} from '~/utils/shopify-queries' +import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' +import { seo } from '~/utils/seo' +import { twMerge } from 'tailwind-merge' +import { useAddToCart } from '~/hooks/useCart' + +export const Route = createFileRoute('/shop/products/$handle')({ + loader: async ({ params }) => { + const product = await getProduct({ data: { handle: params.handle } }) + if (!product) throw notFound() + return { product } + }, + head: ({ loaderData }) => { + const product = loaderData?.product + if (!product) return { meta: [] } + return { + meta: seo({ + title: `${product.seo.title ?? product.title} | TanStack Shop`, + description: product.seo.description ?? undefined, + }), + } + }, + component: ProductPage, +}) + +function ProductPage() { + const { product } = Route.useLoaderData() + const [selected, setSelected] = React.useState>(() => + Object.fromEntries(product.options.map((o) => [o.name, o.values[0]!])), + ) + + const selectedVariant = findMatchingVariant(product.variants.nodes, selected) + const displayPrice = selectedVariant?.price ?? null + + // Use the variant's image if it has one, else fall back to the first product image + const heroImage = selectedVariant?.image ?? product.images.nodes[0] ?? null + + return ( +
    +
    +
    + {heroImage ? ( + {heroImage.altText + ) : null} +
    + +
    +
    +

    {product.title}

    + {displayPrice ? ( +

    + {formatMoney(displayPrice.amount, displayPrice.currencyCode)} +

    + ) : null} +
    + + {product.options.map((option) => { + // Skip auto-generated single-value options like "Title: Default Title" + if (option.values.length <= 1) return null + return ( +
    + {option.name} +
    + {option.values.map((value) => { + const isSelected = selected[option.name] === value + return ( + + ) + })} +
    +
    + ) + })} + + + + {product.descriptionHtml ? ( +
    + ) : null} +
    +
    +
    + ) +} + +function findMatchingVariant( + variants: Array, + selected: Record, +): ProductDetailVariant | undefined { + return variants.find((v) => + v.selectedOptions.every((opt) => selected[opt.name] === opt.value), + ) +} + +function AddToCartButton({ + variant, +}: { + variant: ProductDetailVariant | undefined +}) { + const addToCart = useAddToCart() + + const disabled = !variant || !variant.availableForSale || addToCart.isPending + + const label = addToCart.isSuccess + ? 'Added ✓' + : addToCart.isPending + ? 'Adding…' + : !variant + ? 'Unavailable' + : !variant.availableForSale + ? 'Sold out' + : 'Add to cart' + + // Reset the "Added ✓" confirmation after a moment so repeat adds feel fresh + React.useEffect(() => { + if (!addToCart.isSuccess) return + const id = window.setTimeout(() => addToCart.reset(), 1500) + return () => window.clearTimeout(id) + }, [addToCart.isSuccess, addToCart]) + + return ( +
    + + {addToCart.isError ? ( +

    + {addToCart.error instanceof Error + ? addToCart.error.message + : 'Could not add to cart. Please try again.'} +

    + ) : null} +
    + ) +} + +export type { ProductDetail } diff --git a/src/routes/shop.tsx b/src/routes/shop.tsx new file mode 100644 index 000000000..451481233 --- /dev/null +++ b/src/routes/shop.tsx @@ -0,0 +1,47 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' +import { ShopLayout } from '~/components/shop/ShopLayout' +import { CART_QUERY_KEY } from '~/hooks/useCart' +import { getCart, getCollections } from '~/utils/shop.functions' +import { seo } from '~/utils/seo' + +export const Route = createFileRoute('/shop')({ + loader: async ({ context }) => { + // Fetch collections for the sidebar, and pre-seed the cart into the + // React Query cache so any /shop child page (including the cart page) + // renders with real data on the very first frame — no hydration gap. + const [collections] = await Promise.all([ + getCollections(), + context.queryClient.prefetchQuery({ + queryKey: CART_QUERY_KEY, + queryFn: () => getCart(), + }), + ]) + return { collections } + }, + head: () => ({ + meta: seo({ + title: 'TanStack Shop', + description: + 'Official TanStack apparel, accessories, and stickers. Show your support and rep your favorite open-source toolkit.', + }), + }), + staticData: { + // Providing a Title flips the main Navbar into flyout mode so the shop + // sidebar takes over the primary left rail (same behavior as doc pages). + Title: () => ( + + Shop + + ), + }, + component: ShopRoute, +}) + +function ShopRoute() { + const { collections } = Route.useLoaderData() + return ( + + + + ) +} diff --git a/src/routes/start.$version.index.tsx b/src/routes/start.$version.index.tsx index 78b32dd07..8eac695df 100644 --- a/src/routes/start.$version.index.tsx +++ b/src/routes/start.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import StartLanding from '~/components/landing/StartLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/start/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'start', StartLanding), +export const Route = createFileRoute('/start/$version/')( + createLibraryLandingPage('/start/$version/', 'start', StartLanding), ) diff --git a/src/routes/store.$version.index.tsx b/src/routes/store.$version.index.tsx index cf7704933..7008fb765 100644 --- a/src/routes/store.$version.index.tsx +++ b/src/routes/store.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import StoreLanding from '~/components/landing/StoreLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/store/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'store', StoreLanding), +export const Route = createFileRoute('/store/$version/')( + createLibraryLandingPage('/store/$version/', 'store', StoreLanding), ) diff --git a/src/routes/table.$version.index.tsx b/src/routes/table.$version.index.tsx index 6eedd86f9..cc3bd907a 100644 --- a/src/routes/table.$version.index.tsx +++ b/src/routes/table.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import TableLanding from '~/components/landing/TableLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/table/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'table', TableLanding), +export const Route = createFileRoute('/table/$version/')( + createLibraryLandingPage('/table/$version/', 'table', TableLanding), ) diff --git a/src/routes/virtual.$version.index.tsx b/src/routes/virtual.$version.index.tsx index ceea3b445..eb50c1564 100644 --- a/src/routes/virtual.$version.index.tsx +++ b/src/routes/virtual.$version.index.tsx @@ -2,8 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import VirtualLanding from '~/components/landing/VirtualLanding' import { createLibraryLandingPage } from './-library-landing' -const routePath = '/virtual/$version/' - -export const Route = createFileRoute(routePath)( - createLibraryLandingPage(routePath, 'virtual', VirtualLanding), +export const Route = createFileRoute('/virtual/$version/')( + createLibraryLandingPage('/virtual/$version/', 'virtual', VirtualLanding), ) diff --git a/src/server/shopify/fetch.ts b/src/server/shopify/fetch.ts new file mode 100644 index 000000000..4463d2cb2 --- /dev/null +++ b/src/server/shopify/fetch.ts @@ -0,0 +1,93 @@ +import { env } from '~/utils/env' + +/** + * Shopify store identity. Hard-coded intentionally — these are public-by- + * design values (they appear in every Shopify-hosted checkout URL, order + * email, and receipt) and baking them into source keeps them out of the + * environment-variable scanner's watchlist without losing any security. + * + * Only the tokens are real secrets and remain in env vars. + */ +const SHOPIFY_STORE_DOMAIN = 'tanstack-2.myshopify.com' +const SHOPIFY_API_VERSION = '2026-01' + +type ShopifyFetchInput = { + query: string + variables?: TVariables + /** + * Optional buyer IP, forwarded to Shopify's bot-protection headers. + * Only meaningful with the private token. + */ + buyerIp?: string +} + +type ShopifyResponse = { + data?: TData + errors?: Array<{ message: string; locations?: unknown; path?: unknown }> +} + +class ShopifyError extends Error { + constructor( + message: string, + public readonly errors?: ShopifyResponse['errors'], + ) { + super(message) + this.name = 'ShopifyError' + } +} + +/** + * Server-side Storefront API client. Uses the private access token, which has + * higher rate limits and supports buyer-IP forwarding for bot protection. + * + * Use this in route loaders and server functions only — never in browser code. + */ +export async function shopifyServerFetch< + TData, + TVariables = Record, +>(input: ShopifyFetchInput): Promise { + const token = env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN + + if (!token) { + throw new ShopifyError( + 'Shopify server client is not configured. Set SHOPIFY_PRIVATE_STOREFRONT_TOKEN in the environment.', + ) + } + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Shopify-Storefront-Private-Token': token, + } + if (input.buyerIp) headers['Shopify-Storefront-Buyer-IP'] = input.buyerIp + + const response = await fetch( + `https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`, + { + method: 'POST', + headers, + body: JSON.stringify({ query: input.query, variables: input.variables }), + }, + ) + + if (!response.ok) { + throw new ShopifyError( + `Shopify API error: ${response.status} ${response.statusText}`, + ) + } + + const json = (await response.json()) as ShopifyResponse + + if (json.errors?.length) { + throw new ShopifyError( + json.errors.map((e) => e.message).join('\n'), + json.errors, + ) + } + + if (!json.data) { + throw new ShopifyError('Shopify API returned no data and no errors.') + } + + return json.data +} diff --git a/src/utils/application-starter.server.ts b/src/utils/application-starter.server.ts index 13a69a873..c3a9f5e08 100644 --- a/src/utils/application-starter.server.ts +++ b/src/utils/application-starter.server.ts @@ -17,7 +17,10 @@ import { type ApplicationStarterResult, } from '~/utils/application-starter' import type { LibraryId } from '~/libraries' -import { starterAddonLibraryIds } from '~/components/application-builder/shared' +import { + getStarterMigrationGuideUrl, + starterAddonLibraryIds, +} from '~/components/application-builder/shared' import { getApplicationStarterGuidanceLines, getApplicationStarterPartnerSuggestions, @@ -400,6 +403,10 @@ function buildPromptGenerationRequest({ ].join('\n') const userBrief = getApplicationStarterUserBrief(request.input) const starterGuidanceLines = getApplicationStarterGuidanceLines(request.input) + const migrationGuideUrl = getStarterMigrationGuideUrl(request.input) + const migrationGuideInstruction = migrationGuideUrl + ? `The prompt must instruct the agent to fetch ${migrationGuideUrl} and use it as the primary reference for the migration, following its steps in order.` + : null return [ 'Write a short, natural final prompt for a stronger coding agent.', @@ -417,6 +424,7 @@ function buildPromptGenerationRequest({ 'If the user says things like make it cool, keep it minimal, or do not include something, restate those instructions explicitly in the final prompt instead of compressing them away.', 'Use the resolved starter plan as fixed input. Do not redesign the stack unless the original brief requires sequencing work after scaffolding.', 'Keep the prompt concise and plain-English. Avoid internal process language like fixed input, resolved plan, objective, implementation notes, or deliverable.', + ...(migrationGuideInstruction ? [migrationGuideInstruction] : []), '', `Context: ${request.context}`, `User request: ${userBrief}`, diff --git a/src/utils/env.ts b/src/utils/env.ts index 9bd6a3ede..adf43fb00 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -15,6 +15,11 @@ const serverEnvSchema = v.object({ RESEND_API_KEY: v.optional(v.string()), SENTRY_DSN: v.optional(v.string()), TANSTACK_MCP_ENABLED_TOOLS: v.optional(v.string()), + // Shopify Storefront API token — server-only. Cart reads and mutations + // run through createServerFn (src/utils/shop.functions.ts), so this + // token never reaches the browser. Store domain + API version are + // public-by-design and hard-coded in src/server/shopify/fetch.ts. + SHOPIFY_PRIVATE_STOREFRONT_TOKEN: v.optional(v.string()), }) const clientEnvSchema = v.object({ diff --git a/src/utils/shop.functions.ts b/src/utils/shop.functions.ts new file mode 100644 index 000000000..46baf7e93 --- /dev/null +++ b/src/utils/shop.functions.ts @@ -0,0 +1,262 @@ +import { createServerFn } from '@tanstack/react-start' +import { + deleteCookie, + getCookie, + setCookie, + setResponseHeaders, +} from '@tanstack/react-start/server' +import * as v from 'valibot' +import { shopifyServerFetch } from '~/server/shopify/fetch' +import { + CART_CREATE_MUTATION, + CART_LINES_ADD_MUTATION, + CART_LINES_REMOVE_MUTATION, + CART_LINES_UPDATE_MUTATION, + CART_QUERY, + COLLECTIONS_QUERY, + PRODUCTS_QUERY, + PRODUCT_QUERY, + SHOP_QUERY, + type CartCreateResult, + type CartDetail, + type CartLinesAddResult, + type CartLinesRemoveResult, + type CartLinesUpdateResult, + type CartQueryResult, + type CartUserError, + type CollectionListItem, + type CollectionsQueryResult, + type ProductDetail, + type ProductListItem, + type ProductQueryResult, + type ProductsQueryResult, + type ShopQueryResult, +} from '~/utils/shopify-queries' + +const CART_COOKIE_NAME = 'tanstack_cart_id' +const CART_COOKIE_OPTIONS = { + path: '/', + maxAge: 60 * 60 * 24 * 365, // 1 year + sameSite: 'lax' as const, + httpOnly: true, + secure: process.env.NODE_ENV === 'production', +} + +class CartUserErrorsError extends Error { + constructor(public readonly userErrors: Array) { + super(userErrors.map((e) => e.message).join('\n')) + this.name = 'CartUserErrorsError' + } +} + +function throwIfUserErrors(errs: Array) { + if (errs.length > 0) throw new CartUserErrorsError(errs) +} + +/** + * Edge-cache product browse responses for a few minutes. Catalog data + * doesn't change often and webhook-based revalidation can refine this later. + */ +function setBrowseCacheHeaders() { + setResponseHeaders( + new Headers({ + 'Cache-Control': 'public, max-age=0, must-revalidate', + 'Netlify-CDN-Cache-Control': + 'public, max-age=300, durable, stale-while-revalidate=600', + }), + ) +} + +export const getShop = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + setBrowseCacheHeaders() + const data = await shopifyServerFetch({ + query: SHOP_QUERY, + }) + return data.shop + }, +) + +export const getProducts = createServerFn({ method: 'GET' }).handler( + async (): Promise> => { + setBrowseCacheHeaders() + const result = await shopifyServerFetch< + ProductsQueryResult, + { first: number } + >({ + query: PRODUCTS_QUERY, + variables: { first: 50 }, + }) + return result.products.nodes + }, +) + +export const getCollections = createServerFn({ method: 'GET' }).handler( + async (): Promise> => { + setBrowseCacheHeaders() + const result = await shopifyServerFetch< + CollectionsQueryResult, + { first: number } + >({ + query: COLLECTIONS_QUERY, + variables: { first: 50 }, + }) + return result.collections.nodes + }, +) + +export const getProduct = createServerFn({ method: 'POST' }) + .inputValidator(v.object({ handle: v.string() })) + .handler(async ({ data }): Promise => { + setBrowseCacheHeaders() + const result = await shopifyServerFetch< + ProductQueryResult, + { handle: string } + >({ + query: PRODUCT_QUERY, + variables: { handle: data.handle }, + }) + return result.product + }) + +/* ────────────────────────────────────────────────────────────────────────── + * Cart server functions + * + * Cart ID is stored in an httpOnly cookie. Reads + writes all go through the + * server (higher rate limits via the private token, no token on the client, + * and SSR can load the cart in route loaders). Optimistic updates happen in + * the React Query cache on top of these. + * ────────────────────────────────────────────────────────────────────────── */ + +function setCartResponseHeaders() { + // Cart is per-user; do not edge-cache. + setResponseHeaders( + new Headers({ 'Cache-Control': 'private, no-store, must-revalidate' }), + ) +} + +async function fetchCartById(cartId: string): Promise { + const result = await shopifyServerFetch({ + query: CART_QUERY, + variables: { cartId }, + }) + return result.cart +} + +export const getCart = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + setCartResponseHeaders() + const cartId = getCookie(CART_COOKIE_NAME) + if (!cartId) return null + + const cart = await fetchCartById(cartId) + // If Shopify pruned the cart (expired/abandoned), clear the stale cookie. + if (!cart) deleteCookie(CART_COOKIE_NAME, { path: '/' }) + return cart + }, +) + +export const addToCart = createServerFn({ method: 'POST' }) + .inputValidator( + v.object({ + variantId: v.string(), + quantity: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1), + }), + ) + .handler(async ({ data }): Promise => { + setCartResponseHeaders() + const existingCartId = getCookie(CART_COOKIE_NAME) + + if (!existingCartId) { + const result = await shopifyServerFetch({ + query: CART_CREATE_MUTATION, + variables: { + input: { + lines: [ + { + merchandiseId: data.variantId, + quantity: data.quantity, + }, + ], + }, + }, + }) + throwIfUserErrors(result.cartCreate.userErrors) + const cart = result.cartCreate.cart + if (!cart) throw new Error('Shopify returned no cart after create.') + setCookie(CART_COOKIE_NAME, cart.id, CART_COOKIE_OPTIONS) + return cart + } + + const result = await shopifyServerFetch({ + query: CART_LINES_ADD_MUTATION, + variables: { + cartId: existingCartId, + lines: [{ merchandiseId: data.variantId, quantity: data.quantity }], + }, + }) + throwIfUserErrors(result.cartLinesAdd.userErrors) + const cart = result.cartLinesAdd.cart + // If the existing cart was pruned between requests, fall through and + // create a fresh cart with this line. + if (!cart) { + deleteCookie(CART_COOKIE_NAME, { path: '/' }) + const createResult = await shopifyServerFetch({ + query: CART_CREATE_MUTATION, + variables: { + input: { + lines: [{ merchandiseId: data.variantId, quantity: data.quantity }], + }, + }, + }) + throwIfUserErrors(createResult.cartCreate.userErrors) + const newCart = createResult.cartCreate.cart + if (!newCart) + throw new Error('Shopify returned no cart after recovery create.') + setCookie(CART_COOKIE_NAME, newCart.id, CART_COOKIE_OPTIONS) + return newCart + } + return cart + }) + +export const updateCartLine = createServerFn({ method: 'POST' }) + .inputValidator( + v.object({ + lineId: v.string(), + quantity: v.pipe(v.number(), v.integer(), v.minValue(0)), + }), + ) + .handler(async ({ data }): Promise => { + setCartResponseHeaders() + const cartId = getCookie(CART_COOKIE_NAME) + if (!cartId) throw new Error('No cart exists to update.') + + const result = await shopifyServerFetch({ + query: CART_LINES_UPDATE_MUTATION, + variables: { + cartId, + lines: [{ id: data.lineId, quantity: data.quantity }], + }, + }) + throwIfUserErrors(result.cartLinesUpdate.userErrors) + const cart = result.cartLinesUpdate.cart + if (!cart) throw new Error('Shopify returned no cart after update.') + return cart + }) + +export const removeCartLine = createServerFn({ method: 'POST' }) + .inputValidator(v.object({ lineId: v.string() })) + .handler(async ({ data }): Promise => { + setCartResponseHeaders() + const cartId = getCookie(CART_COOKIE_NAME) + if (!cartId) throw new Error('No cart exists to remove from.') + + const result = await shopifyServerFetch({ + query: CART_LINES_REMOVE_MUTATION, + variables: { cartId, lineIds: [data.lineId] }, + }) + throwIfUserErrors(result.cartLinesRemove.userErrors) + const cart = result.cartLinesRemove.cart + if (!cart) throw new Error('Shopify returned no cart after remove.') + return cart + }) diff --git a/src/utils/shopify-format.ts b/src/utils/shopify-format.ts new file mode 100644 index 000000000..e961d2f3f --- /dev/null +++ b/src/utils/shopify-format.ts @@ -0,0 +1,32 @@ +/** + * Browser-safe formatting helpers for Shopify Storefront API responses. + * Intentionally framework-free — no React, no provider context required. + */ + +export function formatMoney(amount: string | number, currencyCode: string) { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currencyCode, + }).format(typeof amount === 'string' ? Number(amount) : amount) +} + +type ShopifyImageOptions = { + width?: number + height?: number + format?: 'webp' | 'jpg' | 'png' + crop?: 'center' | 'top' | 'bottom' | 'left' | 'right' +} + +/** + * Append Shopify CDN transform parameters to a product image URL. + * Shopify's CDN serves resized/reformatted versions automatically without + * any sign-up; we just need to add the right query params. + */ +export function shopifyImageUrl(url: string, opts: ShopifyImageOptions = {}) { + const u = new URL(url) + if (opts.width) u.searchParams.set('width', String(opts.width)) + if (opts.height) u.searchParams.set('height', String(opts.height)) + if (opts.format) u.searchParams.set('format', opts.format) + if (opts.crop) u.searchParams.set('crop', opts.crop) + return u.toString() +} diff --git a/src/utils/shopify-queries.ts b/src/utils/shopify-queries.ts new file mode 100644 index 000000000..65530a05f --- /dev/null +++ b/src/utils/shopify-queries.ts @@ -0,0 +1,379 @@ +import type { + Cart, + CartLine, + Collection, + Image as StorefrontImage, + MoneyV2, + Product, + ProductOption, + ProductVariant, +} from '@shopify/hydrogen-react/storefront-api-types' + +/** + * GraphQL queries for the Shopify Storefront API. + * + * Result types are hand-picked slices of the full `@shopify/hydrogen-react` + * Storefront API types. When the query count grows we can swap to + * `@shopify/hydrogen-codegen` and regenerate; the consuming code won't + * change because the type shapes match exactly. + */ + +/* ────────────────────────────────────────────────────────────────────────── + * Shop info — used by the smoke test and the shop landing header + * ────────────────────────────────────────────────────────────────────────── */ + +export const SHOP_QUERY = /* GraphQL */ ` + query Shop { + shop { + name + description + primaryDomain { + url + } + } + } +` + +export type ShopQueryResult = { + shop: { + name: string + description: string | null + primaryDomain: { url: string } + } +} + +/* ────────────────────────────────────────────────────────────────────────── + * Product list — used by /shop landing + * ────────────────────────────────────────────────────────────────────────── */ + +export const PRODUCTS_QUERY = /* GraphQL */ ` + query Products($first: Int!) { + products(first: $first, sortKey: BEST_SELLING) { + nodes { + id + handle + title + featuredImage { + url + altText + width + height + } + priceRange { + minVariantPrice { + amount + currencyCode + } + } + } + } + } +` + +export type ProductListItem = Pick & { + featuredImage: + | (Pick | null) + | null + priceRange: { + minVariantPrice: Pick + } +} + +export type ProductsQueryResult = { + products: { nodes: Array } +} + +/* ────────────────────────────────────────────────────────────────────────── + * Single product (PDP) — used by /shop/products/$handle + * ────────────────────────────────────────────────────────────────────────── */ + +export const PRODUCT_QUERY = /* GraphQL */ ` + query Product($handle: String!) { + product(handle: $handle) { + id + handle + title + descriptionHtml + options { + id + name + values + } + images(first: 10) { + nodes { + url + altText + width + height + } + } + variants(first: 100) { + nodes { + id + title + availableForSale + selectedOptions { + name + value + } + price { + amount + currencyCode + } + image { + url + altText + width + height + } + } + } + seo { + title + description + } + } + } +` + +export type ProductDetailVariant = Pick< + ProductVariant, + 'id' | 'title' | 'availableForSale' +> & { + selectedOptions: Array<{ name: string; value: string }> + price: Pick + image: Pick | null +} + +export type ProductDetail = Pick< + Product, + 'id' | 'handle' | 'title' | 'descriptionHtml' +> & { + options: Array> + images: { + nodes: Array> + } + variants: { nodes: Array } + seo: { title: string | null; description: string | null } +} + +export type ProductQueryResult = { + product: ProductDetail | null +} + +/* ────────────────────────────────────────────────────────────────────────── + * Collections list — used to drive the /shop sidebar + * ────────────────────────────────────────────────────────────────────────── */ + +export const COLLECTIONS_QUERY = /* GraphQL */ ` + query Collections($first: Int!) { + collections(first: $first, sortKey: TITLE) { + nodes { + id + handle + title + description + } + } + } +` + +export type CollectionListItem = Pick< + Collection, + 'id' | 'handle' | 'title' | 'description' +> + +export type CollectionsQueryResult = { + collections: { nodes: Array } +} + +/* ────────────────────────────────────────────────────────────────────────── + * Cart — queries + mutations + * + * The cart lives in Shopify. We hit the Storefront API directly from the + * browser with the public token (no server hop needed), and manage the cart + * ID client-side via localStorage. Checkout redirects to `cart.checkoutUrl` + * which is Shopify-hosted. + * ────────────────────────────────────────────────────────────────────────── */ + +const CART_FRAGMENT = /* GraphQL */ ` + fragment CartFields on Cart { + id + checkoutUrl + totalQuantity + cost { + totalAmount { + amount + currencyCode + } + subtotalAmount { + amount + currencyCode + } + totalTaxAmount { + amount + currencyCode + } + } + lines(first: 100) { + nodes { + id + quantity + merchandise { + ... on ProductVariant { + id + title + availableForSale + selectedOptions { + name + value + } + price { + amount + currencyCode + } + image { + url + altText + width + height + } + product { + handle + title + } + } + } + cost { + totalAmount { + amount + currencyCode + } + } + } + } + } +` + +export const CART_QUERY = /* GraphQL */ ` + ${CART_FRAGMENT} + query Cart($cartId: ID!) { + cart(id: $cartId) { + ...CartFields + } + } +` + +export const CART_CREATE_MUTATION = /* GraphQL */ ` + ${CART_FRAGMENT} + mutation CartCreate($input: CartInput!) { + cartCreate(input: $input) { + cart { + ...CartFields + } + userErrors { + field + message + } + } + } +` + +export const CART_LINES_ADD_MUTATION = /* GraphQL */ ` + ${CART_FRAGMENT} + mutation CartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) { + cartLinesAdd(cartId: $cartId, lines: $lines) { + cart { + ...CartFields + } + userErrors { + field + message + } + } + } +` + +export const CART_LINES_UPDATE_MUTATION = /* GraphQL */ ` + ${CART_FRAGMENT} + mutation CartLinesUpdate($cartId: ID!, $lines: [CartLineUpdateInput!]!) { + cartLinesUpdate(cartId: $cartId, lines: $lines) { + cart { + ...CartFields + } + userErrors { + field + message + } + } + } +` + +export const CART_LINES_REMOVE_MUTATION = /* GraphQL */ ` + ${CART_FRAGMENT} + mutation CartLinesRemove($cartId: ID!, $lineIds: [ID!]!) { + cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { + cart { + ...CartFields + } + userErrors { + field + message + } + } + } +` + +export type CartLineMerchandise = Pick< + ProductVariant, + 'id' | 'title' | 'availableForSale' +> & { + selectedOptions: Array<{ name: string; value: string }> + price: Pick + image: Pick | null + product: Pick +} + +export type CartLineDetail = Pick & { + merchandise: CartLineMerchandise + cost: { + totalAmount: Pick + } +} + +export type CartDetail = Pick & { + cost: { + totalAmount: Pick + subtotalAmount: Pick + totalTaxAmount: Pick | null + } + lines: { nodes: Array } +} + +export type CartQueryResult = { + cart: CartDetail | null +} + +export type CartUserError = { field: string[] | null; message: string } + +export type CartCreateResult = { + cartCreate: { cart: CartDetail | null; userErrors: Array } +} + +export type CartLinesAddResult = { + cartLinesAdd: { cart: CartDetail | null; userErrors: Array } +} + +export type CartLinesUpdateResult = { + cartLinesUpdate: { + cart: CartDetail | null + userErrors: Array + } +} + +export type CartLinesRemoveResult = { + cartLinesRemove: { + cart: CartDetail | null + userErrors: Array + } +}