10 Commits

Author SHA1 Message Date
danijel-lf
6a5d602b3b docs: add building-feature-ui guide and update agent instructions
Some checks failed
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
- New guide covers server/client component pattern, hooks, DI prefetch,
  HydrationBoundary hydration, core-ui atomic design reuse, Tailwind
  wiring, seed data, and cross-feature boundaries
- Update AGENTS.md with feature UI folder structure and naming rules
- Update CLAUDE.md with feature UI data fetching convention
2026-05-26 15:59:22 +02:00
danijel-lf
8bc32095c1 fix(web-next): cache bindAll promise for concurrent caller safety
Replace the boolean bound flag with a cached promise so concurrent
callers (layout + page server components) await the same binding
operation. Prevents DI resolution before containers are populated.
2026-05-26 15:59:12 +02:00
danijel-lf
ee45bfe932 refactor(web-next): thin app pages that just render feature components
Pages import feature server components and pass only route-derived
props (slug, id). All data fetching, prefetch, and hydration is owned
by the feature's .server.tsx component.
2026-05-26 15:59:01 +02:00
danijel-lf
7ef0411ffa refactor: split feature UI into .server/.client component pairs
Server components (.server.tsx) resolve controllers from DI, prefetch
data, and wrap client components in HydrationBoundary. Client components
(.client.tsx) use hooks for hydration + background refetch. Barrel
exports server components under clean names — consumers never see the
server/client split.
2026-05-26 15:57:38 +02:00
danijel-lf
9cfa54d382 fix(web-next): add CSS module type declaration 2026-05-26 14:19:23 +02:00
danijel-lf
15d603b00c refactor(web-next): move bindAll to root layout
Centralizes DI initialization in the root layout so individual pages
and the tRPC route handler no longer need to import or call it.
2026-05-26 14:19:00 +02:00
danijel-lf
d71e30bb3a fix(web-next): support USE_DEV_SEED=false and load root .env globally
- Add USE_DEV_SEED=false branch to bindAll dispatcher
- Use dotenv-cli to inject root .env into all Turbo tasks
- Add globalDependencies for .env cache invalidation
2026-05-26 14:13:31 +02:00
danijel-lf
e734530ffe feat(web-next): wire Tailwind CSS v4 via PostCSS
- Add @tailwindcss/postcss + postcss.config.mjs
- Create app.css with @source directives for monorepo packages
- Import app.css in root layout
2026-05-26 14:12:57 +02:00
danijel-lf
a28de6884c feat: add feature UI components with useQuery hooks
- navigation: SiteHeader component + useHeader hook
- blog: ArticleCard, ArticleList, ArticleDetail + useArticleList,
  useArticleBySlug hooks
- marketing-pages: PageHero, PageContent + usePageBySlug,
  useSiteSettings hooks
- App pages now thin server wrappers that prefetch + hydrate;
  feature components own their data fetching via useSuspenseQuery
2026-05-26 14:12:29 +02:00
danijel-lf
3ce71447b3 feat(core-trpc): scaffold tRPC client with React Query providers
- createTRPCContext + useTRPC hook for typed client access
- NextTrpcProvider with SSR-safe absolute URL resolution
- TanstackTrpcProvider for TanStack Start apps
- /api/trpc catch-all route handler in web-next
- Wire NextTrpcProvider into app providers
- Add @repo/core-trpc to transpilePackages
2026-05-26 14:11:27 +02:00
52 changed files with 1765 additions and 118 deletions

View File

@@ -308,13 +308,31 @@ Each feature package exposes exactly these subpath exports:
| Subpath | What it exports | Who consumes |
| ---------------------- | -------------------------------------------------------------------------------------------------- | ----------------------- |
| `.` (root) | Contracts only: types, errors, schemas, `IUseCase` / `IController` aliases, router type, constants | Any consumer |
| `./ui` | Query builders (`queryOptions`), UI components | App packages |
| `./ui` | Hooks (`useX`), components, query builders (`queryOptions`) | App packages |
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
| `./cms` | Payload collections | `@repo/core-cms` only |
| `./di/bind-production` | App boot side-effect swaps mock for real Payload impl | App packages only |
| `./di/bind-dev-seed` | App boot side-effect swaps empty mock for populated mock | App packages, storybook |
Apps import schemas/types from `@repo/<feature>` (root) and React Query builders from `@repo/<feature>/ui`. Deep source paths are not accessible the `exports` map enforces this.
Apps import schemas/types from `@repo/<feature>` (root) and hooks/components from `@repo/<feature>/ui`. Deep source paths are not accessible the `exports` map enforces this.
### Feature UI structure
Each feature's `src/ui/` follows this layout:
```
src/ui/
index.ts # Barrel — exports server components as public API
query.ts # Query builder functions (framework-agnostic)
hooks/
use-<entity>.ts # "use client" — wraps useTRPC + useSuspenseQuery
components/
<entity>-list.server.tsx # Server — DI + prefetch + HydrationBoundary (public)
<entity>-list.client.tsx # "use client" — calls hook (internal only)
<entity>-card.tsx # Presentational (receives props)
```
Server components (`.server.tsx`) are the public API the barrel exports them under clean names (`ArticleList`, not `ArticleListServer`). Client components (`.client.tsx`) are internal only imported by their `.server` counterpart. Server components resolve controllers from DI, prefetch data, and wrap client components in `HydrationBoundary` for SSR + instant hydration. App pages just import and render: `<ArticleList />`, `<PageContent slug="about" />`. See [`docs/guides/building-feature-ui.md`](./docs/guides/building-feature-ui.md) for the full guide.
### Payload-backed features use constructor injection

View File

@@ -59,6 +59,7 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`,
- `docs/guides/coverage.md` — 4-layer coverage cookbook (L0 vitest thresholds, L1 `pnpm coverage:diff`, L2 aggregate, L3 mutation; ADR-020)
- `docs/guides/releasing.md` — release-please workflow: how Conventional Commits become tagged versions + per-package CHANGELOGs (ADR-021)
- `docs/architecture/template-tiers.md` — must-have vs optional packages and how to scaffold the optionals
- `docs/guides/building-feature-ui.md` — Feature UI components, hooks, data fetching (tRPC + React Query), SSR prefetch/hydration, seed data, DI wiring
- `docs/guides/compliance-overview.md` — hub for operator compliance obligations: GDPR, cookie consent, DSR, and pre-launch checklist
## Conformance system
@@ -105,7 +106,8 @@ See `docs/guides/coverage.md` for the cookbook and ADR-020 for the full rational
- **Schemas in the use-case file** Every use case exports `xInputSchema` (a `z.ZodObject` with `.strict()`; `z.object({}).strict()` for void inputs) and, for non-void use cases, `xOutputSchema`. Types: `XInput = z.infer<typeof xInputSchema>` and `XOutput`. Use case body ends with `xOutputSchema.parse(result)` before returning (runtime guarantee against malformed repository data)
- **Controllers receive `unknown` + presenter** Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Non-void controllers define a top-level `function presenter(value: XOutput)` and return `Promise<ReturnType<typeof presenter>>` (identity is fine `return value`); void controllers return `Promise<void>` with no presenter
- **Feature-scoped tRPC error mapping** Each feature has `integrations/api/procedures.ts` exporting `xProcedure = t.procedure.use(defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))` from `@repo/core-shared/trpc/define-error-middleware`. Routers use `xProcedure.input(xInputSchema)` schemas are imported from the use-case file, never redefined inline. `core-shared` never enumerates feature error classes
- **Public surface split** Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (query builders, components) live behind `./ui` (`src/ui/index.ts`). Apps import queries from `@repo/<feature>/ui`, schemas/types from `@repo/<feature>`
- **Public surface split** Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (hooks, components, query builders) live behind `./ui` (`src/ui/index.ts`). Apps import hooks/components from `@repo/<feature>/ui`, schemas/types from `@repo/<feature>`
- **Feature UI owns its data fetching** Each feature's `src/ui/hooks/` contains `"use client"` hooks that wrap `useTRPC` + `useSuspenseQuery`. Connected components in `src/ui/components/` call these hooks. App pages prefetch via `appRouter.createCaller({})` and hydrate via `HydrationBoundary` + `dehydrate` + `setQueryData`. See `docs/guides/building-feature-ui.md`
- **Payload repositories via constructor** Feature packages receive Payload config at constructor time, not as a direct dependency
- **Three binding modes per feature** Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` dev seed; `NODE_ENV="production"` production; otherwise dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev<Entities>()` function that uses the feature's existing factory
- **Binders take a `ctx` arg from `core-shared/di`** `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages guard with `?.` or `if (bus) { ... }` when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders

View File

@@ -16,6 +16,7 @@ const nextConfig = {
"@repo/marketing-pages",
"@repo/media",
"@repo/navigation",
"@repo/core-trpc",
],
};

View File

@@ -19,19 +19,22 @@
"@repo/core-api": "workspace:*",
"@repo/core-cms": "workspace:*",
"@repo/core-shared": "workspace:*",
"@repo/core-trpc": "workspace:^",
"@repo/marketing-pages": "workspace:*",
"@repo/media": "workspace:*",
"@repo/navigation": "workspace:*",
"@sentry/nextjs": "^10.51.0",
"@tanstack/react-query": "^5.66.0",
"@trpc/server": "^11.0.0",
"@tailwindcss/postcss": "^4.3.0",
"@tanstack/react-query": "^5.96.2",
"@trpc/server": "^11.17.0",
"inversify": "^6.2.0",
"next": "^15.3.0",
"payload": "^3.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"reflect-metadata": "^0.2.2",
"superjson": "^2.2.1"
"superjson": "^2.2.1",
"tailwindcss": "^4.1.0"
},
"devDependencies": {
"@playwright/test": "^1.50.0",

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -1,31 +1,12 @@
import { appRouter } from "@repo/core-api";
import { PageContent } from "@repo/marketing-pages/ui";
import { bindAll } from "../../server/bind-production";
export default async function AboutPage() {
await bindAll();
const caller = appRouter.createCaller({});
const page = await caller.marketingPages.pageBySlug({ slug: "about" });
if (!page) {
return (
<main>
<h1>About</h1>
<p>This page hasn't been published yet.</p>
</main>
);
}
return (
<main>
<article>
<header>
<h1>{page.hero.heading}</h1>
{page.hero.subheading ? <p>{page.hero.subheading}</p> : null}
</header>
<pre style={{ whiteSpace: "pre-wrap" }}>
{JSON.stringify(page.layout, null, 2)}
</pre>
</article>
<main className="px-6 py-8">
<PageContent slug="about" />
</main>
);
}

View File

@@ -0,0 +1,13 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@repo/core-api";
const handler = async (req: Request) => {
return fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => ({}),
});
};
export { handler as GET, handler as POST };

View File

@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { appRouter } from "@repo/core-api";
import { ArticleDetail } from "@repo/blog/ui";
import { bindAll } from "../../../server/bind-production";
type PageProps = {
@@ -9,26 +8,10 @@ type PageProps = {
export default async function BlogPostPage({ params }: PageProps) {
await bindAll();
const { slug } = await params;
const caller = appRouter.createCaller({});
const article = await caller.blog.articleBySlug({ slug });
if (!article) notFound();
return (
<main>
<article>
<header>
<h1>{article.title}</h1>
{article.createdAt ? (
<time dateTime={article.createdAt.toISOString()}>
{article.createdAt.toLocaleDateString()}
</time>
) : null}
</header>
<pre style={{ whiteSpace: "pre-wrap" }}>
{JSON.stringify(article.content, null, 2)}
</pre>
</article>
<main className="px-6 py-8">
<ArticleDetail slug={slug} />
</main>
);
}

View File

@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import "../styles/app.css";
import { getNonce } from "@repo/core-shared/security/next";
import { bindAll } from "../server/bind-production";
import { Providers } from "./providers";
export const metadata: Metadata = {
@@ -12,6 +14,7 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
await bindAll();
const nonce = await getNonce();
return (

View File

@@ -1,49 +1,15 @@
import Link from "next/link";
import { appRouter } from "@repo/core-api";
import { ArticleList } from "@repo/blog/ui";
import { bindAll } from "../server/bind-production";
export default async function Home() {
await bindAll();
const caller = appRouter.createCaller({});
const [siteSettings, header, articles] = await Promise.all([
caller.marketingPages.siteSettings({}),
caller.navigation.header({}),
caller.blog.listArticles({ status: "published", limit: 20 }),
]);
return (
<main>
<header>
<h1>{siteSettings.siteName}</h1>
{siteSettings.siteDescription ? (
<p>{siteSettings.siteDescription}</p>
) : null}
<nav>
<ul>
{header.items.map((item) => (
<li key={item.href}>
<Link href={item.href}>{item.label}</Link>
</li>
))}
</ul>
</nav>
</header>
<section>
<h2>Latest articles</h2>
{articles.length === 0 ? (
<p>No published articles yet.</p>
) : (
<ul>
{articles.map((a) => (
<li key={a.id}>
<Link href={`/blog/${a.slug}`}>{a.title}</Link>
</li>
))}
</ul>
)}
</section>
<main className="mx-auto max-w-5xl px-6 py-8">
<h2 className="mb-4 text-2xl font-bold text-foreground">
Latest articles
</h2>
<ArticleList />
</main>
);
}

View File

@@ -1,5 +1,7 @@
"use client";
import { NextTrpcProvider } from "@repo/core-trpc/next";
export function Providers({ children }: { children: React.ReactNode }) {
return <>{children}</>;
return <NextTrpcProvider>{children}</NextTrpcProvider>;
}

1
apps/web-next/src/css.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -28,7 +28,7 @@ import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-see
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
let bound = false;
let bindPromise: Promise<void> | null = null;
// Shared container holds TRACER + LOGGER bindings; per-feature containers
// receive references via parameter passing. This separates the instrumentation
@@ -87,8 +87,6 @@ function resolveJobsDevSeed(): { queue: IJobQueue } {
* feature via `bindProductionX` exports.
*/
export async function bindAllProduction(): Promise<void> {
if (bound) return;
bound = true;
const { tracer, logger } = resolveInstrumentation(); // Rule 0
const { queue } = await resolveJobsProduction();
const resolvedConfig = await config;
@@ -114,8 +112,6 @@ export async function bindAllProduction(): Promise<void> {
* Payload booted. Mutually exclusive with `bindAllProduction()`.
*/
export async function bindAllDevSeed(): Promise<void> {
if (bound) return;
bound = true;
const { tracer, logger } = resolveInstrumentation(); // Rule 0
const { queue } = resolveJobsDevSeed();
@@ -150,21 +146,25 @@ export async function bindAllDevSeed(): Promise<void> {
* When @repo/core-realtime is scaffolded, extend to accept realtime deps
* (IRealtimeBroadcaster, IRealtimeHandlerRegistry) and pass them through.
*/
export async function bindAll(): Promise<void> {
if (process.env.USE_DEV_SEED === "true") {
await bindAllDevSeed();
return;
export function bindAll(): Promise<void> {
if (bindPromise) return bindPromise;
if (process.env.USE_DEV_SEED === "false") {
bindPromise = bindAllProduction();
} else if (process.env.USE_DEV_SEED === "true") {
bindPromise = bindAllDevSeed();
} else if (process.env.NODE_ENV === "production") {
bindPromise = bindAllProduction();
} else {
bindPromise = bindAllDevSeed();
}
if (process.env.NODE_ENV === "production") {
await bindAllProduction();
return;
}
await bindAllDevSeed();
return bindPromise;
}
/** Test-only resets — not exported via package. Used by bind-production.test.ts. */
export function __resetBindStateForTests(): void {
bound = false;
bindPromise = null;
resolvedTracer = null;
resolvedLogger = null;
resolvedQueue = null;

View File

@@ -0,0 +1,10 @@
@import "tailwindcss";
@source "../../../../packages/core-ui/src";
@source "../../../../packages/navigation/src";
@source "../../../../packages/blog/src";
@source "../../../../packages/marketing-pages/src";
@source "../../../../packages/media/src";
@source "../../../../packages/auth/src";
@source "../";
@import "../../../../packages/core-ui/src/styles/theme.css";

View File

@@ -0,0 +1,498 @@
# Building Feature UI — Components, Hooks & Data Fetching
Each feature owns its UI layer inside `src/ui/`. This guide covers how to
create React components that fetch their own data via tRPC + React Query,
how to wire them into Next.js and TanStack Start apps, and how the server
prefetch + client hydration pattern works.
> **Prerequisites:** `@repo/core-trpc` must be scaffolded
> (`pnpm turbo gen core-package trpc`). The tRPC providers must be wired
> into the app's root layout (see [App wiring](#app-wiring) below).
---
## Feature `src/ui/` folder structure
```
packages/<feature>/src/ui/
index.ts # Barrel — re-exports server components as public API
query.ts # Query builder functions (framework-agnostic)
hooks/
use-<entity>.ts # "use client" hooks wrapping tRPC + useSuspenseQuery
use-<entity>-list.ts
components/
<entity>-card.tsx # Presentational (receives props, no hooks)
<entity>-list.server.tsx # Server component — DI + prefetch + HydrationBoundary
<entity>-list.client.tsx # "use client" — calls hook, owns rendering
<entity>-detail.server.tsx
<entity>-detail.client.tsx
```
### Naming convention
| File suffix | Directive | Role | Exported from barrel? |
| ------------------ | ---------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `.server.tsx` | _(none — server by default)_ | Resolves controller from DI, prefetches data, wraps `.client` in `HydrationBoundary` | **Yes** — under the clean name (e.g. `ArticleList`) |
| `.client.tsx` | `"use client"` | Calls hooks, renders UI | **No** — internal to the feature; only imported by its `.server` counterpart |
| `.tsx` (no suffix) | _(none)_ | Presentational — receives data via props, no hooks | Yes, if useful standalone (e.g. `ArticleCard`) |
The **server component is the public face** — the barrel exports it under
the clean component name (`ArticleList`, `ArticleDetail`, `PageContent`).
The `.client.tsx` suffix signals "internal, not for direct consumption" —
consumers never see it.
### Component roles
- **Server components** (`.server.tsx`) — resolve the controller from the
feature's DI container, call it to prefetch data, seed the React Query
cache via `setQueryData`, and wrap the client component in
`HydrationBoundary`. This gives SSR + instant hydration.
- **Client components** (`.client.tsx`) — `"use client"`. Call hooks from
`hooks/` to get data via `useSuspenseQuery`. Handle rendering + interactivity.
Never imported by app pages directly.
- **Hooks** (`hooks/`) — own data fetching; one hook per query.
Always `"use client"`. Import `useTRPC` from `@repo/core-trpc` and
`useSuspenseQuery` from `@tanstack/react-query`.
- **Presentational components** (`.tsx`, no suffix) — receive data via props.
No `"use client"` unless they need browser APIs. Can be shared by
multiple client components.
---
## Component composition & `@repo/core-ui` reuse
Feature components **must** reuse primitives from `@repo/core-ui` rather
than hand-rolling HTML with raw Tailwind classes. `core-ui` follows
**Atomic Design**:
| Tier | Location | Examples | Rule |
| ------------- | ------------------------ | ----------------------------------- | --------------------------------------------- |
| **Atoms** | `core-ui/src/atoms/` | `Button`, `Input`, `Label` | Smallest building blocks. No business logic. |
| **Molecules** | `core-ui/src/molecules/` | `FormField` (Label + Input + error) | Compose atoms. Still generic. |
| **Organisms** | `core-ui/src/organisms/` | `CookieConsentBanner` | Compose molecules/atoms. May own local state. |
| **Templates** | `core-ui/src/templates/` | Page shells, layout grids | Structural — define slots, no data. |
**Import direction is strictly upward:** atoms never import molecules;
molecules never import organisms. The ESLint rule
`atomic-tier-import-direction` enforces this.
### Where feature components fit
Feature components are **consumers** of core-ui, not replacements.
They sit above the atomic tiers:
```
App page (imports feature component, passes route props)
└── Feature server component (.server.tsx — DI + prefetch + HydrationBoundary)
└── Feature client component (.client.tsx — "use client", calls hook)
└── Feature presentational component (receives props)
└── core-ui atoms/molecules (Button, Input, FormField, ...)
```
**Guidelines:**
- **Always check `core-ui` first.** Before creating a `<Card>` or
`<Badge>` in a feature, check if `core-ui` already exports it. Use
Storybook (`pnpm dev --filter @repo/storybook`) or the barrel at
`packages/core-ui/src/index.ts`.
- **If a primitive is missing, add it to `core-ui`** — not to the
feature. Scaffold via `pnpm turbo gen core-ui-component`. Feature
packages should not contain generic UI primitives.
- **Feature components compose, not duplicate.** A feature's
`<ArticleCard>` should render a `core-ui` `<Card>` (when it exists)
with feature-specific content inside — not re-implement card styling.
- **Tailwind utility classes are fine** for layout and spacing within
feature components (flex, grid, padding, margin). But visual
primitives (buttons, inputs, badges, cards) come from `core-ui`.
### Adding `core-ui` as a dependency
Feature packages that use core-ui atoms need:
```jsonc
// package.json
"dependencies": {
"@repo/core-ui": "workspace:*"
}
```
---
## Step 1: Create a hook
Hooks live in `src/ui/hooks/` and wrap a single tRPC query:
```typescript
// packages/blog/src/ui/hooks/use-article-list.ts
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useTRPC } from "@repo/core-trpc";
import type { Article } from "../../entities/models/article";
export function useArticleList(options?: {
status?: "draft" | "published";
limit?: number;
}) {
const trpc = useTRPC();
return useSuspenseQuery(
trpc.blog.listArticles.queryOptions({
status: options?.status ?? "published",
limit: options?.limit ?? 20,
}),
) as { data: Article[] };
}
```
> **TS2742 workaround:** Feature packages set `declaration: true` (from
> the base tsconfig). The `as { data: T }` cast avoids a non-portable
> return type error caused by `@trpc/client` resolving to different
> `.pnpm` paths per package.
### Dependencies
Feature packages that have hooks need these dependencies:
```jsonc
// package.json
"dependencies": {
"@repo/core-trpc": "workspace:*",
"@tanstack/react-query": "^5.66.0",
"@trpc/client": "^11.17.0", // for type portability
"react": "^19.0.0"
}
```
---
## Step 2: Create components
### Server component (public face)
The server component resolves the controller from DI, prefetches, and
wraps the client component in `HydrationBoundary`:
```typescript
// packages/blog/src/ui/components/article-list.server.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@repo/core-trpc";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
import { ArticleList as ArticleListClient } from "./article-list.client";
export async function ArticleList() {
const controller = blogContainer.get<IGetArticlesController>(
BLOG_SYMBOLS.IGetArticlesController,
);
const articles = await controller({ status: "published", limit: 20 });
const queryClient = getQueryClient();
queryClient.setQueryData(
["blog", "listArticles", { input: { status: "published", limit: 20 } }],
articles,
);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ArticleListClient />
</HydrationBoundary>
);
}
```
### Client component (internal)
```typescript
// packages/blog/src/ui/components/article-list.client.tsx
"use client";
import { useArticleList } from "../hooks/use-article-list";
import { ArticleCard } from "./article-card";
export function ArticleList() {
const { data: articles } = useArticleList();
if (articles.length === 0) {
return <p className="text-muted-foreground">No articles yet.</p>;
}
return (
<div className="grid gap-4">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
);
}
```
### Presentational component (receives props)
```typescript
// packages/blog/src/ui/components/article-card.tsx
import type { Article } from "../../entities/models/article";
export type ArticleCardProps = { article: Article };
export function ArticleCard({ article }: ArticleCardProps) {
return (
<article className="rounded-lg border border-border bg-card p-4">
<a href={`/blog/${article.slug}`}>
<h3 className="text-lg font-semibold">{article.title}</h3>
</a>
<time className="text-sm text-muted-foreground"
dateTime={article.createdAt.toISOString()}>
{article.createdAt.toLocaleDateString()}
</time>
</article>
);
}
```
> **No `renderLink` props.** Client components are `"use client"` —
> functions cannot be passed from server components. Use plain `<a>` tags
> or import the framework's Link component directly if the feature has
> that framework as a dependency.
---
## Step 3: Export from the barrel
The barrel exports **server components** under clean names. Client
components are internal — never re-exported:
```typescript
// packages/blog/src/ui/index.ts
export { articleBySlugQuery, listArticlesQuery } from "./query";
export { useArticleList } from "./hooks/use-article-list";
export { useArticleBySlug } from "./hooks/use-article-by-slug";
export { ArticleCard, type ArticleCardProps } from "./components/article-card";
export { ArticleList } from "./components/article-list.server";
export { ArticleDetail } from "./components/article-detail.server";
```
Apps import from `@repo/<feature>/ui` — they get the server component
which handles prefetch + hydration internally:
```typescript
import { ArticleList } from "@repo/blog/ui";
import { SiteHeader } from "@repo/navigation/ui";
```
---
## App wiring
### Next.js (`apps/web-next`)
#### Root layout — DI + providers
`bindAll()` runs once in the root layout. `NextTrpcProvider` wraps all
pages with the tRPC client and React Query.
```typescript
// apps/web-next/src/app/layout.tsx
import { bindAll } from "../server/bind-production";
import { Providers } from "./providers";
export default async function RootLayout({ children }) {
await bindAll();
return (
<html lang="en">
<body><Providers>{children}</Providers></body>
</html>
);
}
```
```typescript
// apps/web-next/src/app/providers.tsx
"use client";
import { NextTrpcProvider } from "@repo/core-trpc/next";
export function Providers({ children }) {
return <NextTrpcProvider>{children}</NextTrpcProvider>;
}
```
#### Pages — just import and render
Feature server components handle prefetch + hydration internally. App
pages are thin — they import the component and pass route-derived props
(slug, id, etc.). No `appRouter`, no `queryClient`, no `HydrationBoundary`
in the app layer:
```typescript
// apps/web-next/src/app/page.tsx
import { ArticleList } from "@repo/blog/ui";
export default function Home() {
return <ArticleList />;
}
```
```typescript
// apps/web-next/src/app/blog/[slug]/page.tsx
import { ArticleDetail } from "@repo/blog/ui";
export default async function BlogPostPage({ params }) {
const { slug } = await params;
return <ArticleDetail slug={slug} />;
}
```
The server component inside the feature resolves its controller from DI,
prefetches data, seeds the query cache, and wraps the client component
in `HydrationBoundary`. This gives:
- Full HTML on first paint (SSR)
- Instant hydration (no loading flash)
- Background refetch on the client via `/api/trpc`
> **Cache key format:** tRPC generates keys as
> `[routerName, procedureName, { input }]`. The `setQueryData` key in the
> server component must match what the client hook's `queryOptions`
> generates, or the client will re-fetch instead of hydrating.
#### tRPC HTTP endpoint
Client-side queries hit `/api/trpc` after hydration:
```typescript
// apps/web-next/src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@repo/core-api";
const handler = async (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
```
### TanStack Start (`apps/web-tanstack`)
Same pattern but with `TanstackTrpcProvider` from `@repo/core-trpc/tanstack`
in the root route, and TanStack Router loaders for server prefetch.
---
## Tailwind CSS in the apps
Both apps and Storybook need their own CSS entry point because Tailwind v4
scans for utility classes only in files it knows about. Monorepo packages
live outside the app directory, so `@source` directives are required.
```css
/* apps/web-next/src/styles/app.css */
@import "tailwindcss";
@source "../../../../packages/core-ui/src";
@source "../../../../packages/navigation/src";
@source "../../../../packages/blog/src";
/* ... all feature packages with UI components */
@import "../../../../packages/core-ui/src/styles/theme.css";
```
- **Next.js** uses `@tailwindcss/postcss` via `postcss.config.mjs`
- **Storybook** uses `@tailwindcss/vite` prepended in `viteFinal`
- **Theme tokens** live in `packages/core-ui/src/styles/theme.css` (single
source of truth). Both `globals.css` and app CSS files import it.
---
## Seed data & DI binding
### Dev seed (`USE_DEV_SEED=true` or default in development)
Each feature has `src/__seeds__/dev.ts` that builds realistic mock data
using factories from `src/__factories__/`. The dev-seed binder
(`src/di/bind-dev-seed.ts`) populates the mock repository with this data.
```typescript
// packages/blog/src/__seeds__/dev.ts
import { articleFactory } from "../__factories__/article.factory";
export function buildDevArticles(): Article[] {
return [
articleFactory.build({
slug: "hello-world",
title: "Hello World",
status: "published",
}),
articleFactory.build({
slug: "second-post",
title: "Second Post",
status: "published",
}),
];
}
```
### Production (`USE_DEV_SEED=false` or `NODE_ENV=production`)
Production binders (`src/di/bind-production.ts`) replace mock repositories
with Payload-backed implementations. They receive a `BindProductionContext`
with `config`, `tracer`, `logger`, `queue`.
### Boot dispatcher
`apps/web-next/src/server/bind-production.ts` picks the mode:
| Condition | Mode |
| --------------------- | -------------------- |
| `USE_DEV_SEED=false` | Production (Payload) |
| `USE_DEV_SEED=true` | Dev seed (mocks) |
| `NODE_ENV=production` | Production |
| Default | Dev seed |
Root `.env` is loaded globally via `dotenv-cli` wrapping Turbo
(`"dev": "dotenv -- turbo run dev"` in root `package.json`).
### Adding a new use case to an existing feature
1. Add the use case to `feature.manifest.ts`
2. Create input/output schemas in the use-case file
3. Write the use case factory + controller
4. Add the tRPC procedure to `integrations/api/router.ts`
5. Wire into both `bind-production.ts` and `bind-dev-seed.ts`
6. Add seed data to `__seeds__/dev.ts` if applicable
7. Create a hook in `src/ui/hooks/use-<name>.ts`
8. Create client component in `src/ui/components/<name>.client.tsx`
9. Create server component in `src/ui/components/<name>.server.tsx`
10. Export the server component from `src/ui/index.ts` under the clean name
---
## Cross-feature boundaries in UI
- Features **may** import another feature's **root barrel** (types, schemas,
errors) but **not** its `./ui` subpath. UI composition across features
happens in the app layer.
- Navigation's `<SiteHeader>` receives `siteName`/`siteDescription` as
**props** — it does not import from `@repo/marketing-pages`. The app
page passes these scalars (the only case where the app fetches data
that crosses feature boundaries).
- If a page renders components from multiple features, the app page
imports and renders them side by side — each feature component handles
its own data fetching internally.
---
## Checklist for new feature UI
- [ ] Check `core-ui` for existing atoms/molecules before creating new primitives
- [ ] Hook in `src/ui/hooks/use-<x>.ts` with `"use client"` + `useSuspenseQuery`
- [ ] Component(s) in `src/ui/components/` composing `core-ui` primitives (atoms -> molecules -> organisms)
- [ ] Barrel exports in `src/ui/index.ts`
- [ ] Server component (`.server.tsx`) with DI resolve + prefetch + `HydrationBoundary`
- [ ] Barrel exports server component under clean name (no `Server` suffix)
- [ ] `@repo/core-trpc`, `@tanstack/react-query`, `@trpc/client`, `react` in `package.json`
- [ ] App page just imports and renders: `<ArticleList />` or `<ArticleDetail slug={slug} />`
- [ ] `@source` directive in app CSS for the feature package (if it has Tailwind classes)
- [ ] Seed data in `__seeds__/dev.ts` (for dev mode)
- [ ] Both `bind-production.ts` and `bind-dev-seed.ts` wire the new use case

View File

@@ -0,0 +1,69 @@
---
package: "@trpc/react-query"
version: "^11.0.0"
tier: core
decision: approved
date: 2026-05-14
deciders: [scaffolded]
adr: null
is-sub-processor: false
processes-pii: false
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: n/a
cve-scan: clean
named-consumer: pass
socketRisk: skip
verification-commands:
- pnpm audit --audit-level=moderate
- npm view @trpc/react-query license
accepted-cves: []
---
## Filter: license
MIT — on the workspace allowlist.
## Filter: types
Ships first-party TypeScript types; deeply integrated with tRPC's type inference.
## Filter: maintenance
Active. Maintained by the tRPC team alongside `@trpc/server` and `@trpc/client`.
## Filter: boundary-fit
Core package. The React Query integration bridge belongs in `core-trpc` alongside its sibling tRPC packages. No boundary rule violation.
## Filter: shadow-check
No other tRPCReact Query bridge in the workspace. No shadow.
## Filter: eu-residency
Client-side integration adapter; no vendor data transmission. n/a.
## Filter: cve-scan
No advisories at adoption time.
## Filter: named-consumer
`core-trpc` re-exports `@trpc/react-query` hooks for use in Next.js feature pages.
## Prompt: replaces
Nothing — this is the initial tRPC scaffold.
## Prompt: migration-cost-out
Hard: hooks are tRPC-procedure-typed; migrating away requires replacing all call sites.
## Prompt: alternatives-considered
This package is the canonical integration point between `@trpc/client` and `@tanstack/react-query`. No viable alternative exists.

View File

@@ -0,0 +1,69 @@
---
package: "@trpc/tanstack-react-query"
version: "^11.1.0"
tier: core
decision: approved
date: 2026-05-14
deciders: [scaffolded]
adr: null
is-sub-processor: false
processes-pii: false
filter-results:
license: MIT
types: native
maintenance: active
boundary-fit: pass
shadow-check: pass
eu-residency: n/a
cve-scan: clean
named-consumer: pass
socketRisk: skip
verification-commands:
- pnpm audit --audit-level=moderate
- npm view @trpc/tanstack-react-query license
accepted-cves: []
---
## Filter: license
MIT — on the workspace allowlist.
## Filter: types
Ships first-party TypeScript types; part of the tRPC v11 adapter suite.
## Filter: maintenance
Active. Maintained by the tRPC team as part of the v11 TanStack Start integration.
## Filter: boundary-fit
Core package. Required for the TanStack Start provider (`core-trpc/tanstack`). No boundary rule violation.
## Filter: shadow-check
No duplicate TanStack adapter in the workspace. No shadow.
## Filter: eu-residency
Client-side integration adapter; no vendor data transmission. n/a.
## Filter: cve-scan
No advisories at adoption time.
## Filter: named-consumer
`core-trpc` exposes a TanStack Start provider via `@trpc/tanstack-react-query` for `apps/web-tanstack`.
## Prompt: replaces
Nothing — this is the initial tRPC scaffold.
## Prompt: migration-cost-out
Hard: the TanStack provider is shaped around this adapter's API; replacing requires re-implementing the provider.
## Prompt: alternatives-considered
This is the official tRPC adapter for TanStack Start. No viable alternative exists.

View File

@@ -7,8 +7,8 @@
"node": ">=20"
},
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"build": "dotenv -- turbo run build",
"dev": "dotenv -- turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"test:e2e": "turbo run test:e2e",
@@ -32,19 +32,20 @@
},
"devDependencies": {
"@ai-hero/sandcastle": "^0.5.10",
"@typescript-eslint/parser": "^8.25.0",
"zod": "^3.25.0",
"@playwright/test": "^1.49.0",
"@stryker-mutator/core": "^8.7.0",
"@stryker-mutator/vitest-runner": "^8.7.0",
"@turbo/gen": "^2.4.0",
"@types/node": "^22.0.0",
"@typescript-eslint/parser": "^8.25.0",
"dotenv-cli": "^11.0.0",
"fallow": "^2.73.0",
"husky": "^9.0.0",
"lint-staged": "^16.0.0",
"prettier": "^3.5.0",
"turbo": "^2.4.0",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"zod": "^3.25.0"
},
"lint-staged": {
"*.{ts,tsx,js,mjs,jsx}": [

View File

@@ -19,9 +19,13 @@
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"@repo/core-trpc": "workspace:^",
"@tanstack/react-query": "^5.66.0",
"@trpc/client": "^11.17.0",
"@trpc/server": "^11.0.0",
"inversify": "^6.2.0",
"payload": "^3.14.0",
"react": "^19.0.0",
"reflect-metadata": "^0.2.2",
"zod": "^3.24.0"
},
@@ -30,6 +34,7 @@
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.1.0"
}

View File

@@ -0,0 +1,23 @@
import type { Article } from "../../entities/models/article";
export type ArticleCardProps = {
article: Article;
};
export function ArticleCard({ article }: ArticleCardProps) {
return (
<article className="rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50">
<a href={`/blog/${article.slug}`}>
<h3 className="text-lg font-semibold text-card-foreground">
{article.title}
</h3>
</a>
<time
className="text-sm text-muted-foreground"
dateTime={article.createdAt.toISOString()}
>
{article.createdAt.toLocaleDateString()}
</time>
</article>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useArticleBySlug } from "../hooks/use-article-by-slug";
export type ArticleDetailProps = {
slug: string;
};
export function ArticleDetail({ slug }: ArticleDetailProps) {
const { data: article } = useArticleBySlug(slug);
if (!article) return null;
return (
<article className="mx-auto max-w-3xl">
<header className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{article.title}</h1>
{article.createdAt ? (
<time
className="mt-2 block text-sm text-muted-foreground"
dateTime={article.createdAt.toISOString()}
>
{article.createdAt.toLocaleDateString()}
</time>
) : null}
</header>
<div className="prose text-foreground">
<pre className="whitespace-pre-wrap text-sm">
{JSON.stringify(article.content, null, 2)}
</pre>
</div>
</article>
);
}

View File

@@ -0,0 +1,24 @@
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@repo/core-trpc";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller";
import { ArticleDetail as ArticleDetailClient } from "./article-detail.client";
export async function ArticleDetail({ slug }: { slug: string }) {
const controller = blogContainer.get<IGetArticleBySlugController>(
BLOG_SYMBOLS.IGetArticleBySlugController,
);
const article = await controller({ slug });
const queryClient = getQueryClient();
queryClient.setQueryData(
["blog", "articleBySlug", { input: { slug } }],
article,
);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ArticleDetailClient slug={slug} />
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useArticleList } from "../hooks/use-article-list";
import { ArticleCard } from "./article-card";
export function ArticleList() {
const { data: articles } = useArticleList();
if (articles.length === 0) {
return <p className="text-muted-foreground">No published articles yet.</p>;
}
return (
<div className="grid gap-4">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@repo/core-trpc";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller";
import { ArticleList as ArticleListClient } from "./article-list.client";
export async function ArticleList() {
const controller = blogContainer.get<IGetArticlesController>(
BLOG_SYMBOLS.IGetArticlesController,
);
const articles = await controller({ status: "published", limit: 20 });
const queryClient = getQueryClient();
queryClient.setQueryData(
["blog", "listArticles", { input: { status: "published", limit: 20 } }],
articles,
);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ArticleListClient />
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useTRPC } from "@repo/core-trpc";
import type { Article } from "../../entities/models/article";
export function useArticleBySlug(slug: string) {
const trpc = useTRPC();
return useSuspenseQuery(trpc.blog.articleBySlug.queryOptions({ slug })) as {
data: Article | null;
};
}

View File

@@ -0,0 +1,18 @@
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useTRPC } from "@repo/core-trpc";
import type { Article } from "../../entities/models/article";
export function useArticleList(options?: {
status?: "draft" | "published";
limit?: number;
}) {
const trpc = useTRPC();
return useSuspenseQuery(
trpc.blog.listArticles.queryOptions({
status: options?.status ?? "published",
limit: options?.limit ?? 20,
}),
) as { data: Article[] };
}

View File

@@ -1 +1,6 @@
export { articleBySlugQuery, listArticlesQuery } from "./query";
export { useArticleList } from "./hooks/use-article-list";
export { useArticleBySlug } from "./hooks/use-article-by-slug";
export { ArticleCard, type ArticleCardProps } from "./components/article-card";
export { ArticleList } from "./components/article-list.server";
export { ArticleDetail } from "./components/article-detail.server";

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,37 @@
{
"name": "@repo/core-trpc",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./next": "./src/providers/next-provider.tsx",
"./tanstack": "./src/providers/tanstack-provider.tsx"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/core-api": "workspace:*",
"@tanstack/react-query": "^5.66.0",
"@trpc/client": "^11.17.0",
"@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0",
"@trpc/tanstack-react-query": "^11.17.0",
"react": "^19.0.0",
"superjson": "^2.2.1"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@types/react": "^19.0.0",
"jsdom": "^25.0.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { useTRPC, TRPCProvider } from "./client";
describe("core-trpc client exports", () => {
it("exports useTRPC hook", () => {
expect(useTRPC).toBeTypeOf("function");
});
it("exports TRPCProvider component", () => {
expect(TRPCProvider).toBeTypeOf("function");
});
});

View File

@@ -0,0 +1,6 @@
"use client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "@repo/core-api";
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();

View File

@@ -0,0 +1,3 @@
export { useTRPC, TRPCProvider } from "./client";
export { getQueryClient } from "./query-client";
export type { AppRouter } from "@repo/core-api";

View File

@@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@repo/core-api";
import { TRPCProvider } from "../client";
import { getQueryClient } from "../query-client";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export function NextTrpcProvider({
children,
trpcUrl = "/api/trpc",
}: {
children: React.ReactNode;
trpcUrl?: string;
}) {
const [queryClient] = useState(() => getQueryClient());
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${getBaseUrl()}${trpcUrl}`,
transformer: superjson,
}),
],
}),
);
return (
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</TRPCProvider>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@repo/core-api";
import { TRPCProvider } from "../client";
import { getQueryClient } from "../query-client";
export function TanstackTrpcProvider({
children,
trpcUrl,
}: {
children: React.ReactNode;
trpcUrl: string;
}) {
const [queryClient] = useState(() => getQueryClient());
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: trpcUrl, transformer: superjson })],
}),
);
return (
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</TRPCProvider>
);
}

View File

@@ -0,0 +1,22 @@
import { QueryClient } from "@tanstack/react-query";
let clientQueryClient: QueryClient | undefined;
const defaultOptions = {
queries: {
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
},
};
export function getQueryClient(): QueryClient {
if (typeof window === "undefined") {
// Server: always create a new instance per request
return new QueryClient({ defaultOptions });
}
// Browser: singleton
if (!clientQueryClient) {
clientQueryClient = new QueryClient({ defaultOptions });
}
return clientQueryClient;
}

View File

@@ -0,0 +1,17 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"lib": ["ES2022", "DOM"],
"jsx": "preserve",
"declaration": false,
"declarationMap": false,
"types": ["vitest/globals", "@testing-library/jest-dom"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core-composition"]
}

View File

@@ -0,0 +1,7 @@
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
export default mergeConfig(jsdomVitestConfig, {
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});

View File

@@ -24,9 +24,13 @@
"dependencies": {
"@repo/auth": "workspace:*",
"@repo/core-shared": "workspace:*",
"@repo/core-trpc": "workspace:^",
"@tanstack/react-query": "^5.66.0",
"@trpc/client": "^11.17.0",
"@trpc/server": "^11.0.0",
"inversify": "^6.2.0",
"payload": "^3.14.0",
"react": "^19.0.0",
"reflect-metadata": "^0.2.2",
"zod": "^3.24.0"
},
@@ -35,6 +39,7 @@
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.1.0"
}

View File

@@ -0,0 +1,25 @@
"use client";
import { usePageBySlug } from "../hooks/use-page-by-slug";
import { PageHero } from "./page-hero";
export type PageContentProps = {
slug: string;
};
export function PageContent({ slug }: PageContentProps) {
const { data: page } = usePageBySlug(slug);
if (!page) return null;
return (
<article className="mx-auto max-w-3xl">
<PageHero hero={page.hero} />
<div className="prose text-foreground">
<pre className="whitespace-pre-wrap text-sm">
{JSON.stringify(page.layout, null, 2)}
</pre>
</div>
</article>
);
}

View File

@@ -0,0 +1,35 @@
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@repo/core-trpc";
import { marketingPagesContainer } from "../../di/container";
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
import type { IGetPageBySlugController } from "../../interface-adapters/controllers/get-page-by-slug.controller";
import { PageContent as PageContentClient } from "./page-content.client";
export async function PageContent({ slug }: { slug: string }) {
const controller = marketingPagesContainer.get<IGetPageBySlugController>(
MARKETING_PAGES_SYMBOLS.IGetPageBySlugController,
);
const page = await controller({ slug });
const queryClient = getQueryClient();
queryClient.setQueryData(
["marketingPages", "pageBySlug", { input: { slug } }],
page,
);
if (!page) {
return (
<main className="mx-auto max-w-3xl px-6 py-8">
<h1 className="text-3xl font-bold text-foreground">Not found</h1>
<p className="mt-2 text-muted-foreground">
This page hasn&apos;t been published yet.
</p>
</main>
);
}
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PageContentClient slug={slug} />
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,16 @@
import type { Hero } from "../../entities/models/page";
export type PageHeroProps = {
hero: Hero;
};
export function PageHero({ hero }: PageHeroProps) {
return (
<header className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{hero.heading}</h1>
{hero.subheading ? (
<p className="mt-2 text-lg text-muted-foreground">{hero.subheading}</p>
) : null}
</header>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useTRPC } from "@repo/core-trpc";
import type { Page } from "../../entities/models/page";
export function usePageBySlug(slug: string) {
const trpc = useTRPC();
return useSuspenseQuery(
trpc.marketingPages.pageBySlug.queryOptions({ slug }),
) as { data: Page | null };
}

View File

@@ -0,0 +1,12 @@
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useTRPC } from "@repo/core-trpc";
import type { SiteSettings } from "../../entities/models/site-settings";
export function useSiteSettings() {
const trpc = useTRPC();
return useSuspenseQuery(
trpc.marketingPages.siteSettings.queryOptions({}),
) as { data: SiteSettings };
}

View File

@@ -1 +1,5 @@
export { pageBySlugQuery, siteSettingsQuery } from "./query";
export { usePageBySlug } from "./hooks/use-page-by-slug";
export { useSiteSettings } from "./hooks/use-site-settings";
export { PageHero, type PageHeroProps } from "./components/page-hero";
export { PageContent } from "./components/page-content.server";

View File

@@ -19,9 +19,13 @@
},
"dependencies": {
"@repo/core-shared": "workspace:*",
"@repo/core-trpc": "workspace:^",
"@tanstack/react-query": "^5.66.0",
"@trpc/client": "^11.17.0",
"@trpc/server": "^11.0.0",
"inversify": "^6.2.0",
"payload": "^3.14.0",
"react": "^19.0.0",
"reflect-metadata": "^0.2.2",
"zod": "^3.24.0"
},
@@ -30,6 +34,7 @@
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.1.0"
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useHeader } from "../hooks/use-header";
export type SiteHeaderProps = {
siteName: string;
siteDescription?: string;
};
export function SiteHeader({ siteName, siteDescription }: SiteHeaderProps) {
const { data: header } = useHeader();
return (
<header className="border-b border-border bg-background px-6 py-4">
<div className="mx-auto flex max-w-5xl items-center justify-between">
<div>
<a href="/">
<span className="text-lg font-semibold text-foreground">
{siteName}
</span>
</a>
{siteDescription ? (
<p className="text-sm text-muted-foreground">{siteDescription}</p>
) : null}
</div>
<nav>
<ul className="flex gap-4">
{header.items.map((item) => (
<li key={item.href}>
<a
href={item.href}
{...(item.external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
className="text-sm font-medium text-foreground hover:text-primary"
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,27 @@
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@repo/core-trpc";
import { navigationContainer } from "../../di/container";
import { NAVIGATION_SYMBOLS } from "../../di/symbols";
import type { IGetHeaderController } from "../../interface-adapters/controllers/get-header.controller";
import { SiteHeader as SiteHeaderClient } from "./site-header.client";
export async function SiteHeader({
siteName,
siteDescription,
}: {
siteName: string;
siteDescription?: string;
}) {
const controller = navigationContainer.get<IGetHeaderController>(
NAVIGATION_SYMBOLS.IGetHeaderController,
);
const header = await controller({});
const queryClient = getQueryClient();
queryClient.setQueryData(["navigation", "header", { input: {} }], header);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<SiteHeaderClient siteName={siteName} siteDescription={siteDescription} />
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useTRPC } from "@repo/core-trpc";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function useHeader() {
const trpc = useTRPC();
return useSuspenseQuery(trpc.navigation.header.queryOptions({})) as {
data: {
items: { label: string; href: string; external: boolean }[];
logoId?: string;
};
};
}

View File

@@ -1 +1,3 @@
export { headerQuery } from "./query";
export { useHeader } from "./hooks/use-header";
export { SiteHeader } from "./components/site-header.server";

489
pnpm-lock.yaml generated
View File

@@ -8,7 +8,7 @@ importers:
.:
devDependencies:
"@ai-hero/sandcastle":
specifier: "^0.5.10"
specifier: ^0.5.10
version: 0.5.10(@effect/cluster@0.57.0(@effect/platform@0.95.0(effect@3.21.2))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.21.2))(effect@3.21.2)(ioredis@5.10.1))(@effect/platform@0.95.0(effect@3.21.2))(effect@3.21.2))(@effect/workflow@0.17.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.21.2))(effect@3.21.2)(ioredis@5.10.1))(@effect/platform@0.95.0(effect@3.21.2))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.21.2))(effect@3.21.2)(ioredis@5.10.1))(@effect/platform@0.95.0(effect@3.21.2))(effect@3.21.2))(@effect/typeclass@0.39.0(effect@3.21.2))
"@playwright/test":
specifier: ^1.49.0
@@ -28,6 +28,9 @@ importers:
"@typescript-eslint/parser":
specifier: ^8.25.0
version: 8.58.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
dotenv-cli:
specifier: ^11.0.0
version: 11.0.0
fallow:
specifier: ^2.73.0
version: 2.73.0
@@ -182,6 +185,9 @@ importers:
"@repo/core-shared":
specifier: workspace:*
version: link:../../packages/core-shared
"@repo/core-trpc":
specifier: workspace:^
version: link:../../packages/core-trpc
"@repo/marketing-pages":
specifier: workspace:*
version: link:../../packages/marketing-pages
@@ -194,12 +200,15 @@ importers:
"@sentry/nextjs":
specifier: ^10.51.0
version: 10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@15.5.14(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2)
"@tailwindcss/postcss":
specifier: ^4.3.0
version: 4.3.0
"@tanstack/react-query":
specifier: ^5.66.0
specifier: ^5.96.2
version: 5.96.2(react@19.2.4)
"@trpc/server":
specifier: ^11.0.0
version: 11.16.0(typescript@5.9.3)
specifier: ^11.17.0
version: 11.17.0(typescript@5.9.3)
inversify:
specifier: ^6.2.0
version: 6.2.2(reflect-metadata@0.2.2)
@@ -221,6 +230,9 @@ importers:
superjson:
specifier: ^2.2.1
version: 2.2.6
tailwindcss:
specifier: ^4.1.0
version: 4.2.2
devDependencies:
"@playwright/test":
specifier: ^1.50.0
@@ -389,6 +401,15 @@ importers:
"@repo/core-shared":
specifier: workspace:*
version: link:../core-shared
"@repo/core-trpc":
specifier: workspace:^
version: link:../core-trpc
"@tanstack/react-query":
specifier: ^5.66.0
version: 5.96.2(react@19.2.4)
"@trpc/client":
specifier: ^11.17.0
version: 11.17.0(@trpc/server@11.16.0(typescript@5.9.3))(typescript@5.9.3)
"@trpc/server":
specifier: ^11.0.0
version: 11.16.0(typescript@5.9.3)
@@ -398,6 +419,9 @@ importers:
payload:
specifier: ^3.14.0
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
react:
specifier: ^19.0.0
version: 19.2.4
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -417,6 +441,9 @@ importers:
"@types/node":
specifier: ^22.0.0
version: 22.19.17
"@types/react":
specifier: ^19.0.0
version: 19.2.14
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
@@ -871,6 +898,58 @@ importers:
specifier: ^5.8.0
version: 5.9.3
packages/core-trpc:
dependencies:
"@repo/core-api":
specifier: workspace:*
version: link:../core-api
"@tanstack/react-query":
specifier: ^5.66.0
version: 5.96.2(react@19.2.4)
"@trpc/client":
specifier: ^11.17.0
version: 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)
"@trpc/react-query":
specifier: ^11.17.0
version: 11.17.0(@tanstack/react-query@5.96.2(react@19.2.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.4)(typescript@5.9.3)
"@trpc/server":
specifier: ^11.17.0
version: 11.17.0(typescript@5.9.3)
"@trpc/tanstack-react-query":
specifier: ^11.17.0
version: 11.17.0(@tanstack/react-query@5.96.2(react@19.2.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.4)(typescript@5.9.3)
react:
specifier: ^19.0.0
version: 19.2.4
superjson:
specifier: ^2.2.1
version: 2.2.6
devDependencies:
"@repo/core-eslint":
specifier: workspace:*
version: link:../core-eslint
"@repo/core-testing":
specifier: workspace:*
version: link:../core-testing
"@repo/core-typescript":
specifier: workspace:*
version: link:../core-typescript
"@testing-library/jest-dom":
specifier: ^6.5.0
version: 6.9.1
"@testing-library/react":
specifier: ^16.0.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
"@types/react":
specifier: ^19.0.0
version: 19.2.14
jsdom:
specifier: ^25.0.0
version: 25.0.1
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
packages/core-typescript:
devDependencies:
"@vitest/coverage-v8":
@@ -943,6 +1022,15 @@ importers:
"@repo/core-shared":
specifier: workspace:*
version: link:../core-shared
"@repo/core-trpc":
specifier: workspace:^
version: link:../core-trpc
"@tanstack/react-query":
specifier: ^5.66.0
version: 5.96.2(react@19.2.4)
"@trpc/client":
specifier: ^11.17.0
version: 11.17.0(@trpc/server@11.16.0(typescript@5.9.3))(typescript@5.9.3)
"@trpc/server":
specifier: ^11.0.0
version: 11.16.0(typescript@5.9.3)
@@ -952,6 +1040,9 @@ importers:
payload:
specifier: ^3.14.0
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
react:
specifier: ^19.0.0
version: 19.2.4
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -971,6 +1062,9 @@ importers:
"@types/node":
specifier: ^22.0.0
version: 22.19.17
"@types/react":
specifier: ^19.0.0
version: 19.2.14
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
@@ -1023,6 +1117,15 @@ importers:
"@repo/core-shared":
specifier: workspace:*
version: link:../core-shared
"@repo/core-trpc":
specifier: workspace:^
version: link:../core-trpc
"@tanstack/react-query":
specifier: ^5.66.0
version: 5.96.2(react@19.2.4)
"@trpc/client":
specifier: ^11.17.0
version: 11.17.0(@trpc/server@11.16.0(typescript@5.9.3))(typescript@5.9.3)
"@trpc/server":
specifier: ^11.0.0
version: 11.16.0(typescript@5.9.3)
@@ -1032,6 +1135,9 @@ importers:
payload:
specifier: ^3.14.0
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
react:
specifier: ^19.0.0
version: 19.2.4
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -1051,6 +1157,9 @@ importers:
"@types/node":
specifier: ^22.0.0
version: 22.19.17
"@types/react":
specifier: ^19.0.0
version: 19.2.14
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
@@ -1099,6 +1208,13 @@ packages:
"@vercel/sandbox":
optional: true
"@alloc/quick-lru@5.2.0":
resolution:
{
integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==,
}
engines: { node: ">=10" }
"@ampproject/remapping@2.3.0":
resolution:
{
@@ -6994,6 +7110,12 @@ packages:
integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==,
}
"@tailwindcss/node@4.3.0":
resolution:
{
integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==,
}
"@tailwindcss/oxide-android-arm64@4.2.2":
resolution:
{
@@ -7003,6 +7125,15 @@ packages:
cpu: [arm64]
os: [android]
"@tailwindcss/oxide-android-arm64@4.3.0":
resolution:
{
integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==,
}
engines: { node: ">= 20" }
cpu: [arm64]
os: [android]
"@tailwindcss/oxide-darwin-arm64@4.2.2":
resolution:
{
@@ -7012,6 +7143,15 @@ packages:
cpu: [arm64]
os: [darwin]
"@tailwindcss/oxide-darwin-arm64@4.3.0":
resolution:
{
integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==,
}
engines: { node: ">= 20" }
cpu: [arm64]
os: [darwin]
"@tailwindcss/oxide-darwin-x64@4.2.2":
resolution:
{
@@ -7021,6 +7161,15 @@ packages:
cpu: [x64]
os: [darwin]
"@tailwindcss/oxide-darwin-x64@4.3.0":
resolution:
{
integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==,
}
engines: { node: ">= 20" }
cpu: [x64]
os: [darwin]
"@tailwindcss/oxide-freebsd-x64@4.2.2":
resolution:
{
@@ -7030,6 +7179,15 @@ packages:
cpu: [x64]
os: [freebsd]
"@tailwindcss/oxide-freebsd-x64@4.3.0":
resolution:
{
integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==,
}
engines: { node: ">= 20" }
cpu: [x64]
os: [freebsd]
"@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2":
resolution:
{
@@ -7039,6 +7197,15 @@ packages:
cpu: [arm]
os: [linux]
"@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0":
resolution:
{
integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==,
}
engines: { node: ">= 20" }
cpu: [arm]
os: [linux]
"@tailwindcss/oxide-linux-arm64-gnu@4.2.2":
resolution:
{
@@ -7048,6 +7215,15 @@ packages:
cpu: [arm64]
os: [linux]
"@tailwindcss/oxide-linux-arm64-gnu@4.3.0":
resolution:
{
integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==,
}
engines: { node: ">= 20" }
cpu: [arm64]
os: [linux]
"@tailwindcss/oxide-linux-arm64-musl@4.2.2":
resolution:
{
@@ -7057,6 +7233,15 @@ packages:
cpu: [arm64]
os: [linux]
"@tailwindcss/oxide-linux-arm64-musl@4.3.0":
resolution:
{
integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==,
}
engines: { node: ">= 20" }
cpu: [arm64]
os: [linux]
"@tailwindcss/oxide-linux-x64-gnu@4.2.2":
resolution:
{
@@ -7066,6 +7251,15 @@ packages:
cpu: [x64]
os: [linux]
"@tailwindcss/oxide-linux-x64-gnu@4.3.0":
resolution:
{
integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==,
}
engines: { node: ">= 20" }
cpu: [x64]
os: [linux]
"@tailwindcss/oxide-linux-x64-musl@4.2.2":
resolution:
{
@@ -7075,6 +7269,15 @@ packages:
cpu: [x64]
os: [linux]
"@tailwindcss/oxide-linux-x64-musl@4.3.0":
resolution:
{
integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==,
}
engines: { node: ">= 20" }
cpu: [x64]
os: [linux]
"@tailwindcss/oxide-wasm32-wasi@4.2.2":
resolution:
{
@@ -7090,6 +7293,21 @@ packages:
- "@emnapi/wasi-threads"
- tslib
"@tailwindcss/oxide-wasm32-wasi@4.3.0":
resolution:
{
integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==,
}
engines: { node: ">=14.0.0" }
cpu: [wasm32]
bundledDependencies:
- "@napi-rs/wasm-runtime"
- "@emnapi/core"
- "@emnapi/runtime"
- "@tybys/wasm-util"
- "@emnapi/wasi-threads"
- tslib
"@tailwindcss/oxide-win32-arm64-msvc@4.2.2":
resolution:
{
@@ -7099,6 +7317,15 @@ packages:
cpu: [arm64]
os: [win32]
"@tailwindcss/oxide-win32-arm64-msvc@4.3.0":
resolution:
{
integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==,
}
engines: { node: ">= 20" }
cpu: [arm64]
os: [win32]
"@tailwindcss/oxide-win32-x64-msvc@4.2.2":
resolution:
{
@@ -7108,6 +7335,15 @@ packages:
cpu: [x64]
os: [win32]
"@tailwindcss/oxide-win32-x64-msvc@4.3.0":
resolution:
{
integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==,
}
engines: { node: ">= 20" }
cpu: [x64]
os: [win32]
"@tailwindcss/oxide@4.2.2":
resolution:
{
@@ -7115,6 +7351,19 @@ packages:
}
engines: { node: ">= 20" }
"@tailwindcss/oxide@4.3.0":
resolution:
{
integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==,
}
engines: { node: ">= 20" }
"@tailwindcss/postcss@4.3.0":
resolution:
{
integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==,
}
"@tailwindcss/vite@4.2.2":
resolution:
{
@@ -7569,6 +7818,28 @@ packages:
"@trpc/server": 11.16.0
typescript: ">=5.7.2"
"@trpc/client@11.17.0":
resolution:
{
integrity: sha512-KpJBFrbKTDeVCFv/3ckL1XBBH5Yssn8hethI/rUy7GIpTj+VzjtPjykDqJpzobuVOz+d26cXCSu1t4I6MYI5Zg==,
}
hasBin: true
peerDependencies:
"@trpc/server": 11.17.0
typescript: ">=5.7.2"
"@trpc/react-query@11.17.0":
resolution:
{
integrity: sha512-AGcl5YAF8NnhBmyJ6PqJqKb1M5VTGSoNRNqJ3orct4o4epdcg0GWhW+qT9q6gPzs/2ImIwYCdfFpgNGdZ9yLHA==,
}
peerDependencies:
"@tanstack/react-query": ^5.80.3
"@trpc/client": 11.17.0
"@trpc/server": 11.17.0
react: ">=18.2.0"
typescript: ">=5.7.2"
"@trpc/server@11.16.0":
resolution:
{
@@ -7578,6 +7849,28 @@ packages:
peerDependencies:
typescript: ">=5.7.2"
"@trpc/server@11.17.0":
resolution:
{
integrity: sha512-jbAOUe0PpUTCYqziyu+8vYXZdDXPudZgnEhWCQ2NjKnVEjfE93RqHTt1oycZJv/HNf51YlRXfEEwSIAbb161rw==,
}
hasBin: true
peerDependencies:
typescript: ">=5.7.2"
"@trpc/tanstack-react-query@11.17.0":
resolution:
{
integrity: sha512-OFxjvCgisP0yaCj7lgP6qPaFwJvJDgnrAUxH3cNIPPR89TpYbndn5vsdKNkc7EnvER9b6wq9Yz8aCcPcOBpDXg==,
}
hasBin: true
peerDependencies:
"@tanstack/react-query": ^5.80.3
"@trpc/client": 11.17.0
"@trpc/server": 11.17.0
react: ">=18.2.0"
typescript: ">=5.7.2"
"@turbo/darwin-64@2.9.4":
resolution:
{
@@ -9914,6 +10207,20 @@ packages:
}
engines: { node: ">=20" }
dotenv-cli@11.0.0:
resolution:
{
integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==,
}
hasBin: true
dotenv-expand@12.0.3:
resolution:
{
integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==,
}
engines: { node: ">=12" }
dotenv@16.0.3:
resolution:
{
@@ -10131,6 +10438,13 @@ packages:
}
engines: { node: ">=10.13.0" }
enhanced-resolve@5.22.0:
resolution:
{
integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==,
}
engines: { node: ">=10.13.0" }
entities@1.1.2:
resolution:
{
@@ -13301,6 +13615,14 @@ packages:
engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 }
hasBin: true
nanoid@3.3.12:
resolution:
{
integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==,
}
engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 }
hasBin: true
natural-compare@1.4.0:
resolution:
{
@@ -14032,6 +14354,13 @@ packages:
}
engines: { node: ^10 || ^12 || >=14 }
postcss@8.5.15:
resolution:
{
integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==,
}
engines: { node: ^10 || ^12 || >=14 }
postcss@8.5.8:
resolution:
{
@@ -15472,6 +15801,12 @@ packages:
integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==,
}
tailwindcss@4.3.0:
resolution:
{
integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==,
}
tapable@2.3.2:
resolution:
{
@@ -15479,6 +15814,13 @@ packages:
}
engines: { node: ">=6" }
tapable@2.3.3:
resolution:
{
integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==,
}
engines: { node: ">=6" }
tar-stream@3.2.0:
resolution:
{
@@ -16822,6 +17164,8 @@ snapshots:
- bufferutil
- utf-8-validate
"@alloc/quick-lru@5.2.0": {}
"@ampproject/remapping@2.3.0":
dependencies:
"@jridgewell/gen-mapping": 0.3.13
@@ -21111,42 +21455,88 @@ snapshots:
source-map-js: 1.2.1
tailwindcss: 4.2.2
"@tailwindcss/node@4.3.0":
dependencies:
"@jridgewell/remapping": 2.3.5
enhanced-resolve: 5.22.0
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.3.0
"@tailwindcss/oxide-android-arm64@4.2.2":
optional: true
"@tailwindcss/oxide-android-arm64@4.3.0":
optional: true
"@tailwindcss/oxide-darwin-arm64@4.2.2":
optional: true
"@tailwindcss/oxide-darwin-arm64@4.3.0":
optional: true
"@tailwindcss/oxide-darwin-x64@4.2.2":
optional: true
"@tailwindcss/oxide-darwin-x64@4.3.0":
optional: true
"@tailwindcss/oxide-freebsd-x64@4.2.2":
optional: true
"@tailwindcss/oxide-freebsd-x64@4.3.0":
optional: true
"@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2":
optional: true
"@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0":
optional: true
"@tailwindcss/oxide-linux-arm64-gnu@4.2.2":
optional: true
"@tailwindcss/oxide-linux-arm64-gnu@4.3.0":
optional: true
"@tailwindcss/oxide-linux-arm64-musl@4.2.2":
optional: true
"@tailwindcss/oxide-linux-arm64-musl@4.3.0":
optional: true
"@tailwindcss/oxide-linux-x64-gnu@4.2.2":
optional: true
"@tailwindcss/oxide-linux-x64-gnu@4.3.0":
optional: true
"@tailwindcss/oxide-linux-x64-musl@4.2.2":
optional: true
"@tailwindcss/oxide-linux-x64-musl@4.3.0":
optional: true
"@tailwindcss/oxide-wasm32-wasi@4.2.2":
optional: true
"@tailwindcss/oxide-wasm32-wasi@4.3.0":
optional: true
"@tailwindcss/oxide-win32-arm64-msvc@4.2.2":
optional: true
"@tailwindcss/oxide-win32-arm64-msvc@4.3.0":
optional: true
"@tailwindcss/oxide-win32-x64-msvc@4.2.2":
optional: true
"@tailwindcss/oxide-win32-x64-msvc@4.3.0":
optional: true
"@tailwindcss/oxide@4.2.2":
optionalDependencies:
"@tailwindcss/oxide-android-arm64": 4.2.2
@@ -21162,6 +21552,29 @@ snapshots:
"@tailwindcss/oxide-win32-arm64-msvc": 4.2.2
"@tailwindcss/oxide-win32-x64-msvc": 4.2.2
"@tailwindcss/oxide@4.3.0":
optionalDependencies:
"@tailwindcss/oxide-android-arm64": 4.3.0
"@tailwindcss/oxide-darwin-arm64": 4.3.0
"@tailwindcss/oxide-darwin-x64": 4.3.0
"@tailwindcss/oxide-freebsd-x64": 4.3.0
"@tailwindcss/oxide-linux-arm-gnueabihf": 4.3.0
"@tailwindcss/oxide-linux-arm64-gnu": 4.3.0
"@tailwindcss/oxide-linux-arm64-musl": 4.3.0
"@tailwindcss/oxide-linux-x64-gnu": 4.3.0
"@tailwindcss/oxide-linux-x64-musl": 4.3.0
"@tailwindcss/oxide-wasm32-wasi": 4.3.0
"@tailwindcss/oxide-win32-arm64-msvc": 4.3.0
"@tailwindcss/oxide-win32-x64-msvc": 4.3.0
"@tailwindcss/postcss@4.3.0":
dependencies:
"@alloc/quick-lru": 5.2.0
"@tailwindcss/node": 4.3.0
"@tailwindcss/oxide": 4.3.0
postcss: 8.5.15
tailwindcss: 4.3.0
"@tailwindcss/vite@4.2.2(vite@6.4.2(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))":
dependencies:
"@tailwindcss/node": 4.2.2
@@ -21908,10 +22321,40 @@ snapshots:
"@trpc/server": 11.16.0(typescript@5.9.3)
typescript: 5.9.3
"@trpc/client@11.17.0(@trpc/server@11.16.0(typescript@5.9.3))(typescript@5.9.3)":
dependencies:
"@trpc/server": 11.16.0(typescript@5.9.3)
typescript: 5.9.3
"@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)":
dependencies:
"@trpc/server": 11.17.0(typescript@5.9.3)
typescript: 5.9.3
"@trpc/react-query@11.17.0(@tanstack/react-query@5.96.2(react@19.2.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.4)(typescript@5.9.3)":
dependencies:
"@tanstack/react-query": 5.96.2(react@19.2.4)
"@trpc/client": 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)
"@trpc/server": 11.17.0(typescript@5.9.3)
react: 19.2.4
typescript: 5.9.3
"@trpc/server@11.16.0(typescript@5.9.3)":
dependencies:
typescript: 5.9.3
"@trpc/server@11.17.0(typescript@5.9.3)":
dependencies:
typescript: 5.9.3
"@trpc/tanstack-react-query@11.17.0(@tanstack/react-query@5.96.2(react@19.2.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.4)(typescript@5.9.3)":
dependencies:
"@tanstack/react-query": 5.96.2(react@19.2.4)
"@trpc/client": 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)
"@trpc/server": 11.17.0(typescript@5.9.3)
react: 19.2.4
typescript: 5.9.3
"@turbo/darwin-64@2.9.4":
optional: true
@@ -22845,7 +23288,7 @@ snapshots:
dotenv: 17.4.2
exsolve: 1.0.8
giget: 3.2.0
jiti: 2.6.1
jiti: 2.7.0
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 2.1.0
@@ -23371,6 +23814,17 @@ snapshots:
dependencies:
type-fest: 5.6.0
dotenv-cli@11.0.0:
dependencies:
cross-spawn: 7.0.6
dotenv: 17.4.2
dotenv-expand: 12.0.3
minimist: 1.2.8
dotenv-expand@12.0.3:
dependencies:
dotenv: 16.6.1
dotenv@16.0.3: {}
dotenv@16.6.1: {}
@@ -23444,6 +23898,11 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.2
enhanced-resolve@5.22.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
entities@1.1.2: {}
entities@2.2.0: {}
@@ -25287,7 +25746,7 @@ snapshots:
get-port-please: 3.2.0
h3: 1.15.11
http-shutdown: 1.2.2
jiti: 2.6.1
jiti: 2.7.0
mlly: 1.8.2
node-forge: 1.4.0
pathe: 2.0.3
@@ -25758,6 +26217,8 @@ snapshots:
nanoid@3.3.11: {}
nanoid@3.3.12: {}
natural-compare@1.4.0: {}
neo-async@2.6.2: {}
@@ -26352,6 +26813,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.15:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
@@ -27262,8 +27729,12 @@ snapshots:
tailwindcss@4.2.2: {}
tailwindcss@4.3.0: {}
tapable@2.3.2: {}
tapable@2.3.3: {}
tar-stream@3.2.0:
dependencies:
b4a: 1.8.1
@@ -27623,7 +28094,7 @@ snapshots:
dependencies:
citty: 0.1.6
defu: 6.1.7
jiti: 2.6.1
jiti: 2.7.0
knitwork: 1.3.0
scule: 1.3.0
@@ -28012,7 +28483,7 @@ snapshots:
acorn-import-phases: 1.0.4(acorn@8.16.0)
browserslist: 4.28.2
chrome-trace-event: 1.0.4
enhanced-resolve: 5.20.1
enhanced-resolve: 5.22.0
es-module-lexer: 2.1.0
eslint-scope: 5.1.1
events: 3.3.0
@@ -28022,7 +28493,7 @@ snapshots:
mime-db: 1.54.0
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.2
tapable: 2.3.3
terser-webpack-plugin: 5.5.0(webpack@5.106.2)
watchpack: 2.5.1
webpack-sources: 3.4.1

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://turborepo.dev/schema.json",
"globalDependencies": [".env"],
"globalEnv": [
"CI",
"DATABASE_URL",