feat: integration with trpc

This commit is contained in:
2025-05-01 21:03:37 +07:00
parent 315c8da82a
commit 00fbc8581e
19 changed files with 291 additions and 84 deletions
+12 -29
View File
@@ -1,46 +1,29 @@
import { ReactNode } from 'react';
import configPromise from '@payload-config';
import { getPayload } from 'payload';
import { ReactNode, Suspense } from 'react';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient, trpc } from '@/trpc/server';
import Navbar from './navbar';
import Footer from './footer';
import SearchFilters from './search-filters';
import { Category } from '@/payload-types';
import { CustomCategory } from './type';
import SearchFilters, { SearchFiltersSkeleton } from './search-filters';
interface LayoutProps {
children: ReactNode;
}
const Layout = async ({ children }: LayoutProps) => {
const payload = await getPayload({
config: configPromise,
});
const data = await payload.find({
collection: 'categories',
depth: 1,
where: {
parent: {
exists: false,
},
},
sort: 'name',
});
const formattedData: CustomCategory[] = data.docs.map((category) => ({
...category,
subcategories: (category.subcategories?.docs ?? []).map((doc) => ({
...(doc as Category),
subcategories: undefined,
})),
}));
const queryClient = getQueryClient();
void queryClient.prefetchQuery(trpc.categories.getMany.queryOptions());
return (
<div className="flex flex-col min-h-screen">
<Navbar />
<SearchFilters data={formattedData} />
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<SearchFiltersSkeleton />}>
<SearchFilters />
</Suspense>
</HydrationBoundary>
<div className="flex-1 bg-[#F4F4F0]">{children}</div>
@@ -1,32 +1,36 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { CustomCategory } from '../type';
import { useState } from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useTRPC } from '@/trpc/client';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoriesSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: CustomCategory[];
}
const CategoriesSidebar = ({
open,
onOpenChange,
data,
}: CategoriesSidebarProps) => {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.categories.getMany.queryOptions());
const router = useRouter();
const [parentCategories, setParentCategories] = useState<
CustomCategory[] | null
CategoriesGetManyOutput | null
>(null);
const [selectedCategory, setSelectedCategory] =
useState<CustomCategory | null>(null);
useState<CategoriesGetManyOutput[1] | null>(null);
// If we have parent categories, show those, otherwise show root categories
const currentCategories = parentCategories ?? data ?? [];
@@ -37,9 +41,9 @@ const CategoriesSidebar = ({
onOpenChange(open);
};
const handleCategoryClick = (category: CustomCategory) => {
const handleCategoryClick = (category: CategoriesGetManyOutput[1]) => {
if (category.subcategories && category.subcategories.length > 0) {
setParentCategories(category.subcategories as CustomCategory[]);
setParentCategories(category.subcategories as CategoriesGetManyOutput);
setSelectedCategory(category);
} else {
// This is a leaf category (no subcategories)
@@ -7,11 +7,11 @@ import { cn } from '@/lib/utils';
import CategoryDropdown from './category-dropdown';
import { CustomCategory } from '../type';
import CategoriesSidebar from './categories-sidebar';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoriesProps {
data: CustomCategory[];
data: CategoriesGetManyOutput;
}
const Categories = ({ data }: CategoriesProps) => {
@@ -69,7 +69,6 @@ const Categories = ({ data }: CategoriesProps) => {
<CategoriesSidebar
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
data={data}
/>
{/* Hidden div to measure all items */}
@@ -7,11 +7,11 @@ import { cn } from '@/lib/utils';
import useDropdownPosition from './use-dropdown-position';
import SubCategoryMenu from './subcategory-menu';
import { CustomCategory } from '../type';
import Link from 'next/link';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoryDropdownProps {
category: CustomCategory;
category: CategoriesGetManyOutput[1];
isActive?: boolean;
isNavigationHovered?: boolean;
}
+23 -7
View File
@@ -1,15 +1,19 @@
import { CustomCategory } from '../type';
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
import Categories from './categories';
import SearchInput from './search-input';
interface SearchFiltersProps {
data: CustomCategory[];
}
const SearchFilters = () => {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.categories.getMany.queryOptions());
const SearchFilters = ({ data }: SearchFiltersProps) => {
return (
<div className="px-4 lg:px-12 py-8 border-b flex flex-col gap-4 w-full">
<SearchInput data={data} />
<div className="px-4 lg:px-12 py-8 border-b flex flex-col gap-4 w-full bg-[#F5F5F5]">
<SearchInput />
<div className="hidden lg:block">
<Categories data={data} />
@@ -18,4 +22,16 @@ const SearchFilters = ({ data }: SearchFiltersProps) => {
);
};
export const SearchFiltersSkeleton = () => {
return (
<div className="px-4 lg:px-12 py-8 border-b flex flex-col gap-4 w-full bg-[#F5F5F5]">
<SearchInput disabled />
<div className="hidden lg:block">
<div className="h-11" />
</div>
</div>
);
};
export default SearchFilters;
@@ -8,21 +8,17 @@ import { Button } from '@/components/ui/button';
import CategoriesSidebar from './categories-sidebar';
import { CustomCategory } from '../type';
interface SearchInputProps {
disabled?: boolean;
data: CustomCategory[];
}
const SearchInput = ({ disabled, data }: SearchInputProps) => {
const SearchInput = ({ disabled }: SearchInputProps) => {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
<div>
<div className="flex items-center gap-2 w-full">
<CategoriesSidebar
data={data}
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
@@ -1,10 +1,10 @@
import { Category } from '@/payload-types';
import Link from 'next/link';
import { CustomCategory } from '../type';
import { Category } from '@/payload-types';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface SubCategoryMenuProps {
category: CustomCategory;
category: CategoriesGetManyOutput[1];
isOpen: boolean;
position: {
top: number;
-5
View File
@@ -1,5 +0,0 @@
import { Category } from "@/payload-types";
export type CustomCategory = Category & {
subcategories: Category[];
}
+11
View File
@@ -0,0 +1,11 @@
import { createTRPCContext } from '@/trpc/init';
import { appRouter } from '@/trpc/routers/_app';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
});
export { handler as GET, handler as POST };
+18 -17
View File
@@ -1,28 +1,29 @@
import type { Metadata } from "next";
import { DM_Sans } from "next/font/google";
import "./globals.css";
import type { Metadata } from 'next';
import { DM_Sans } from 'next/font/google';
import { TRPCReactProvider } from '@/trpc/client';
import './globals.css';
const dmSans = DM_Sans({
subsets: ["latin"],
subsets: ['latin'],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: 'Create Next App',
description: 'Generated by create next app',
};
export default function RootLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${dmSans.className} antialiased`}
>
{children}
</body>
</html>
);
return (
<html lang="en">
<body className={`${dmSans.className} antialiased`}>
<TRPCReactProvider>{children}</TRPCReactProvider>
</body>
</html>
);
}
@@ -0,0 +1,28 @@
import { baseProcedure, createTRPCRouter } from "@/trpc/init";
import { Category } from '@/payload-types';
export const categoriesRouter = createTRPCRouter({
getMany: baseProcedure.query(async ({ ctx }) => {
const data = await ctx.db.find({
collection: 'categories',
depth: 1,
where: {
parent: {
exists: false,
},
},
sort: 'name',
});
const formattedData = data.docs.map((category) => ({
...category,
subcategories: (category.subcategories?.docs ?? []).map((doc) => ({
...(doc as Category),
subcategories: undefined,
})),
}));
return formattedData;
})
})
+5
View File
@@ -0,0 +1,5 @@
import { inferRouterOutputs } from "@trpc/server";
import { AppRouter } from "@/trpc/routers/_app";
export type CategoriesGetManyOutput = inferRouterOutputs<AppRouter>["categories"]["getMany"];
+61
View File
@@ -0,0 +1,61 @@
'use client';
// ^-- to make sure we can mount the Provider from a server component
import { useState } from 'react';
import type { QueryClient } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCContext } from '@trpc/tanstack-react-query';
import superjson from 'superjson';
import { makeQueryClient } from './query-client';
import type { AppRouter } from './routers/_app';
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
let browserQueryClient: QueryClient;
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient();
}
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
function getUrl() {
const base = (() => {
if (typeof window !== 'undefined') return '';
return process.env.NEXT_PUBLIC_APP_URL;
})();
return `${base}/api/trpc`;
}
export function TRPCReactProvider(
props: Readonly<{
children: React.ReactNode;
}>
) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
transformer: superjson,
url: getUrl(),
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{props.children}
</TRPCProvider>
</QueryClientProvider>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { cache } from 'react';
import { initTRPC } from '@trpc/server';
import { getPayload } from 'payload';
import config from '@payload-config';
import superjson from 'superjson';
export const createTRPCContext = cache(async () => {
/**
* @see: https://trpc.io/docs/server/context
*/
return { userId: 'user_123' };
});
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create({
/**
* @see https://trpc.io/docs/server/data-transformers
*/
transformer: superjson,
});
// Base router and procedure helpers
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure.use(async ({ next }) => {
const payload = await getPayload({ config });
return next({ ctx: { db: payload } });
});
+24
View File
@@ -0,0 +1,24 @@
import {
defaultShouldDehydrateQuery,
QueryClient,
} from '@tanstack/react-query';
import superjson from 'superjson';
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: superjson.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
hydrate: {
deserializeData: superjson.deserialize,
},
},
});
}
+8
View File
@@ -0,0 +1,8 @@
import { categoriesRouter } from '@/modules/categories/server/procedures';
import { createTRPCRouter } from '../init';
export const appRouter = createTRPCRouter({
categories: categoriesRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
+14
View File
@@ -0,0 +1,14 @@
import 'server-only'; // <-- ensure this file cannot be imported from the client
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import { cache } from 'react';
import { createTRPCContext } from './init';
import { makeQueryClient } from './query-client';
import { appRouter } from './routers/_app';
// IMPORTANT: Create a stable getter for the query client that
// will return the same client during the same request.
export const getQueryClient = cache(makeQueryClient);
export const trpc = createTRPCOptionsProxy({
ctx: createTRPCContext,
router: appRouter,
queryClient: getQueryClient,
});