From a28de6884c1395db576b0dcaf7c1a0f4fe169c68 Mon Sep 17 00:00:00 2001 From: danijel-lf Date: Tue, 26 May 2026 14:12:29 +0200 Subject: [PATCH] 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 --- apps/web-next/src/app/about/page.tsx | 34 ++++++----- apps/web-next/src/app/blog/[slug]/page.tsx | 30 +++++----- apps/web-next/src/app/page.tsx | 56 ++++++++----------- packages/blog/package.json | 5 ++ .../blog/src/ui/components/article-card.tsx | 23 ++++++++ .../blog/src/ui/components/article-detail.tsx | 34 +++++++++++ .../blog/src/ui/components/article-list.tsx | 20 +++++++ .../blog/src/ui/hooks/use-article-by-slug.ts | 12 ++++ .../blog/src/ui/hooks/use-article-list.ts | 18 ++++++ packages/blog/src/ui/index.ts | 8 +++ packages/marketing-pages/package.json | 5 ++ .../src/ui/components/page-content.tsx | 25 +++++++++ .../src/ui/components/page-hero.tsx | 16 ++++++ .../src/ui/hooks/use-page-by-slug.ts | 12 ++++ .../src/ui/hooks/use-site-settings.ts | 12 ++++ packages/marketing-pages/src/ui/index.ts | 4 ++ packages/navigation/package.json | 5 ++ .../src/ui/components/site-header.tsx | 46 +++++++++++++++ .../navigation/src/ui/hooks/use-header.ts | 15 +++++ packages/navigation/src/ui/index.ts | 2 + 20 files changed, 320 insertions(+), 62 deletions(-) create mode 100644 packages/blog/src/ui/components/article-card.tsx create mode 100644 packages/blog/src/ui/components/article-detail.tsx create mode 100644 packages/blog/src/ui/components/article-list.tsx create mode 100644 packages/blog/src/ui/hooks/use-article-by-slug.ts create mode 100644 packages/blog/src/ui/hooks/use-article-list.ts create mode 100644 packages/marketing-pages/src/ui/components/page-content.tsx create mode 100644 packages/marketing-pages/src/ui/components/page-hero.tsx create mode 100644 packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts create mode 100644 packages/marketing-pages/src/ui/hooks/use-site-settings.ts create mode 100644 packages/navigation/src/ui/components/site-header.tsx create mode 100644 packages/navigation/src/ui/hooks/use-header.ts diff --git a/apps/web-next/src/app/about/page.tsx b/apps/web-next/src/app/about/page.tsx index d9438b6..9c25016 100644 --- a/apps/web-next/src/app/about/page.tsx +++ b/apps/web-next/src/app/about/page.tsx @@ -1,31 +1,37 @@ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { appRouter } from "@repo/core-api"; +import { getQueryClient } from "@repo/core-trpc"; +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 queryClient = getQueryClient(); + const page = await caller.marketingPages.pageBySlug({ slug: "about" }); + queryClient.setQueryData( + ["marketingPages", "pageBySlug", { input: { slug: "about" } }], + page, + ); + if (!page) { return ( -
-

About

-

This page hasn't been published yet.

+
+

About

+

+ This page hasn't been published yet. +

); } return ( -
-
-
-

{page.hero.heading}

- {page.hero.subheading ?

{page.hero.subheading}

: null} -
-
-          {JSON.stringify(page.layout, null, 2)}
-        
-
-
+ +
+ +
+
); } diff --git a/apps/web-next/src/app/blog/[slug]/page.tsx b/apps/web-next/src/app/blog/[slug]/page.tsx index c044e33..2a99c33 100644 --- a/apps/web-next/src/app/blog/[slug]/page.tsx +++ b/apps/web-next/src/app/blog/[slug]/page.tsx @@ -1,5 +1,8 @@ import { notFound } from "next/navigation"; +import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { appRouter } from "@repo/core-api"; +import { getQueryClient } from "@repo/core-trpc"; +import { ArticleDetail } from "@repo/blog/ui"; import { bindAll } from "../../../server/bind-production"; type PageProps = { @@ -10,25 +13,22 @@ export default async function BlogPostPage({ params }: PageProps) { await bindAll(); const { slug } = await params; const caller = appRouter.createCaller({}); + const queryClient = getQueryClient(); + const article = await caller.blog.articleBySlug({ slug }); if (!article) notFound(); + queryClient.setQueryData( + ["blog", "articleBySlug", { input: { slug } }], + article, + ); + return ( -
-
-
-

{article.title}

- {article.createdAt ? ( - - ) : null} -
-
-          {JSON.stringify(article.content, null, 2)}
-        
-
-
+ +
+ +
+
); } diff --git a/apps/web-next/src/app/page.tsx b/apps/web-next/src/app/page.tsx index df98af7..f025554 100644 --- a/apps/web-next/src/app/page.tsx +++ b/apps/web-next/src/app/page.tsx @@ -1,10 +1,14 @@ -import Link from "next/link"; +import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { appRouter } from "@repo/core-api"; +import { getQueryClient } from "@repo/core-trpc"; +import { SiteHeader } from "@repo/navigation/ui"; +import { ArticleList } from "@repo/blog/ui"; import { bindAll } from "../server/bind-production"; export default async function Home() { await bindAll(); const caller = appRouter.createCaller({}); + const queryClient = getQueryClient(); const [siteSettings, header, articles] = await Promise.all([ caller.marketingPages.siteSettings({}), @@ -12,38 +16,24 @@ export default async function Home() { caller.blog.listArticles({ status: "published", limit: 20 }), ]); - return ( -
-
-

{siteSettings.siteName}

- {siteSettings.siteDescription ? ( -

{siteSettings.siteDescription}

- ) : null} - -
+ queryClient.setQueryData(["navigation", "header", { input: {} }], header); + queryClient.setQueryData( + ["blog", "listArticles", { input: { status: "published", limit: 20 } }], + articles, + ); -
-

Latest articles

- {articles.length === 0 ? ( -

No published articles yet.

- ) : ( -
    - {articles.map((a) => ( -
  • - {a.title} -
  • - ))} -
- )} -
-
+ return ( + + +
+

+ Latest articles +

+ +
+
); } diff --git a/packages/blog/package.json b/packages/blog/package.json index e950062..19876f0 100644 --- a/packages/blog/package.json +++ b/packages/blog/package.json @@ -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" } diff --git a/packages/blog/src/ui/components/article-card.tsx b/packages/blog/src/ui/components/article-card.tsx new file mode 100644 index 0000000..5d3dbf0 --- /dev/null +++ b/packages/blog/src/ui/components/article-card.tsx @@ -0,0 +1,23 @@ +import type { Article } from "../../entities/models/article"; + +export type ArticleCardProps = { + article: Article; +}; + +export function ArticleCard({ article }: ArticleCardProps) { + return ( + + ); +} diff --git a/packages/blog/src/ui/components/article-detail.tsx b/packages/blog/src/ui/components/article-detail.tsx new file mode 100644 index 0000000..f615066 --- /dev/null +++ b/packages/blog/src/ui/components/article-detail.tsx @@ -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.title}

+ {article.createdAt ? ( + + ) : null} +
+
+
+          {JSON.stringify(article.content, null, 2)}
+        
+
+
+ ); +} diff --git a/packages/blog/src/ui/components/article-list.tsx b/packages/blog/src/ui/components/article-list.tsx new file mode 100644 index 0000000..b78dd28 --- /dev/null +++ b/packages/blog/src/ui/components/article-list.tsx @@ -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

No published articles yet.

; + } + + return ( +
+ {articles.map((article) => ( + + ))} +
+ ); +} diff --git a/packages/blog/src/ui/hooks/use-article-by-slug.ts b/packages/blog/src/ui/hooks/use-article-by-slug.ts new file mode 100644 index 0000000..7c688d0 --- /dev/null +++ b/packages/blog/src/ui/hooks/use-article-by-slug.ts @@ -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; + }; +} diff --git a/packages/blog/src/ui/hooks/use-article-list.ts b/packages/blog/src/ui/hooks/use-article-list.ts new file mode 100644 index 0000000..2898ae9 --- /dev/null +++ b/packages/blog/src/ui/hooks/use-article-list.ts @@ -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[] }; +} diff --git a/packages/blog/src/ui/index.ts b/packages/blog/src/ui/index.ts index 8129565..a20f21e 100644 --- a/packages/blog/src/ui/index.ts +++ b/packages/blog/src/ui/index.ts @@ -1 +1,9 @@ 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"; +export { + ArticleDetail, + type ArticleDetailProps, +} from "./components/article-detail"; diff --git a/packages/marketing-pages/package.json b/packages/marketing-pages/package.json index d328a85..3c5ca17 100644 --- a/packages/marketing-pages/package.json +++ b/packages/marketing-pages/package.json @@ -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" } diff --git a/packages/marketing-pages/src/ui/components/page-content.tsx b/packages/marketing-pages/src/ui/components/page-content.tsx new file mode 100644 index 0000000..0f539f8 --- /dev/null +++ b/packages/marketing-pages/src/ui/components/page-content.tsx @@ -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 ( +
+ +
+
+          {JSON.stringify(page.layout, null, 2)}
+        
+
+
+ ); +} diff --git a/packages/marketing-pages/src/ui/components/page-hero.tsx b/packages/marketing-pages/src/ui/components/page-hero.tsx new file mode 100644 index 0000000..6e9507d --- /dev/null +++ b/packages/marketing-pages/src/ui/components/page-hero.tsx @@ -0,0 +1,16 @@ +import type { Hero } from "../../entities/models/page"; + +export type PageHeroProps = { + hero: Hero; +}; + +export function PageHero({ hero }: PageHeroProps) { + return ( +
+

{hero.heading}

+ {hero.subheading ? ( +

{hero.subheading}

+ ) : null} +
+ ); +} diff --git a/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts b/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts new file mode 100644 index 0000000..62787d5 --- /dev/null +++ b/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts @@ -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 }; +} diff --git a/packages/marketing-pages/src/ui/hooks/use-site-settings.ts b/packages/marketing-pages/src/ui/hooks/use-site-settings.ts new file mode 100644 index 0000000..c0ed45e --- /dev/null +++ b/packages/marketing-pages/src/ui/hooks/use-site-settings.ts @@ -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 }; +} diff --git a/packages/marketing-pages/src/ui/index.ts b/packages/marketing-pages/src/ui/index.ts index 0a540ae..38dbca1 100644 --- a/packages/marketing-pages/src/ui/index.ts +++ b/packages/marketing-pages/src/ui/index.ts @@ -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, type PageContentProps } from "./components/page-content"; diff --git a/packages/navigation/package.json b/packages/navigation/package.json index 8a914f1..ecb97e9 100644 --- a/packages/navigation/package.json +++ b/packages/navigation/package.json @@ -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" } diff --git a/packages/navigation/src/ui/components/site-header.tsx b/packages/navigation/src/ui/components/site-header.tsx new file mode 100644 index 0000000..14028f9 --- /dev/null +++ b/packages/navigation/src/ui/components/site-header.tsx @@ -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 ( +
+
+
+ + + {siteName} + + + {siteDescription ? ( +

{siteDescription}

+ ) : null} +
+ +
+
+ ); +} diff --git a/packages/navigation/src/ui/hooks/use-header.ts b/packages/navigation/src/ui/hooks/use-header.ts new file mode 100644 index 0000000..a9c618c --- /dev/null +++ b/packages/navigation/src/ui/hooks/use-header.ts @@ -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; + }; + }; +} diff --git a/packages/navigation/src/ui/index.ts b/packages/navigation/src/ui/index.ts index ad7dc5f..75e6e9a 100644 --- a/packages/navigation/src/ui/index.ts +++ b/packages/navigation/src/ui/index.ts @@ -1 +1,3 @@ export { headerQuery } from "./query"; +export { useHeader } from "./hooks/use-header"; +export { SiteHeader, type SiteHeaderProps } from "./components/site-header";