https://www.youtube.com/watch?v=ED2H_y6dmC8
6:08:36
10 - 재사용 가능한 Entity 컴포넌트
src/components/entity-components.tsx
- EntityHeader - 유연한 버튼 처리
- onNew prop: 함수 실행 (예: 모달 열기, API 호출)
- newButtonHref prop: 페이지 이동 (Next.js Link)
- 둘 다 없으면 버튼 미표시
- TypeScript Discriminated Union으로 타입 안전성 보장
- EntityContainer - 일관된 레이아웃
- 헤더, 검색, 콘텐츠, 페이지네이션 슬롯 제공
- 반응형 디자인 (모바일/데스크톱)
- 최대 너비 제한으로 가독성 향상
import Link from "next/link";
import { Button } from "./ui/button";
import { PlusIcon } from "lucide-react";
import { ReactNode } from "react";
type EntityHeaderProps = {
title: string;
description?:string;
newButtonLabel?:string;
disabled?:boolean;
isCreating?:boolean
} & (
| {onNew: ()=> void; newButtonHref?:never}
| {newButtonHref: string; onNew?:never}
| {onNew?: never; newButtonHref?:never}
)
export const EntityHeader = ({
title,
description,
onNew,
newButtonHref,
newButtonLabel,
disabled,
isCreating
}:EntityHeaderProps) => {
return(
<div className="flex flex-row items-center justify-between gap-x-4">
<div className="flex flex-col">
<h1 className="text-lg md:text-xl font-semibold">{title}</h1>
{description &&(
<p className="text-xs md:text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{onNew && !newButtonHref && (
<Button
disabled={isCreating || disabled}
size="sm"
onClick={onNew}
>
<PlusIcon className="size-4"/>
{newButtonLabel}
</Button>
)}
{newButtonHref && !onNew && (
<Button
size="sm"
asChild
>
<Link href={newButtonHref} prefetch>
<PlusIcon className="size-4"/>
{newButtonLabel}
</Link>
</Button>
)}
</div>
)
}
type EntityContainerProps = {
children: ReactNode;
header?:ReactNode;
search?:ReactNode;
pagination?:ReactNode;
}
export const EntityContainer = ({
children,
header,
search,
pagination,
}:EntityContainerProps) => {
return(
<div className="p-4 md:px-10 md:py-6 h-full">
<div className="mx-auto max-w-screen-xl w-full flex flex-col gap-y-8 h-full">
{header}
<div className="flex flex-col gap-y-4 h-full">
{search}
{children}
</div>
{pagination}
</div>
</div>
)
}
11 - Workflows UI 구현
11-1. Workflows 컴포넌트 완성
src/app/features/workflows/components/workflows.tsx
'use client'
import { EntityContainer, EntityHeader } from "@/components/entity-components";
import { useSuspenseWorkflows } from "../hooks/use-workflows"
export const WorkflowsList = () => {
const workflows = useSuspenseWorkflows();
return(
<div className="flex-1 flex justify-center items-center">
<p>
{JSON.stringify(workflows.data, null, 2)}
</p>
</div>
)
}
export const WorkflowsHeader = ({disabled} : {disabled?:boolean}) => {
return(
<>
<EntityHeader
title="Workflows"
description="Create and manage your workflows"
onNew={()=>{}}
newButtonLabel="New workflow"
disabled={disabled}
isCreating={false}
/>
</>
)
}
export const WorkflowsContainer = ({
children
}:{
children:React.ReactNode
})=>{
return(
<EntityContainer
header={<WorkflowsHeader/>}
search={<></>}
pagination={<></>}
>
{children}
</EntityContainer>
)
}
11-2. 페이지 구조 업데이트
src/app/(dashboard)/(rest)/workflows/page.tsx
import { WorkflowsContainer, WorkflowsList } from "@/app/features/workflows/components/workflows";
import { prefetchWorkflows } from "@/app/features/workflows/server/prefetch";
import { requireAuth } from "@/lib/auth-utils";
import { HydrateClient } from "@/trpc/server";
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
const Page = async() => {
await requireAuth()
prefetchWorkflows()
return(
<WorkflowsContainer>
<HydrateClient>
<ErrorBoundary fallback={<div>Error!</div>}>
<Suspense fallback={<p>Loading...</p>}>
<WorkflowsList/>
</Suspense>
</ErrorBoundary>
</HydrateClient>
</WorkflowsContainer>
)
}
export default Page;

12 - Workflow 생성 기능 구현
12-1. Create Workflow Hook 추가
src/app/features/workflows/hooks/use-workflows.ts
- onSuccess: 생성 성공 시
- 성공 토스트 메시지 표시
- 생성된 워크플로우 상세 페이지로 이동
- 워크플로우 목록 캐시 무효화 (새로고침)
- onError: 실패 시 에러 토스트 표시
import { useTRPC } from "@/trpc/client"
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
export const useSuspenseWorkflows = () => {
const trpc = useTRPC();
return useSuspenseQuery(trpc.workflows.getMany.queryOptions());
}
export const useCreateWorkflow = () => {
const router = useRouter()
const queryClient = useQueryClient();
const trpc = useTRPC();
return useMutation(trpc.workflows.create.mutationOptions({
onSuccess: (data) => {
toast.success(`Workflow "${data.name}" created` )
router.push(`/workflows/${data.id}`)
queryClient.invalidateQueries(
trpc.workflows.getMany.queryOptions()
)
},
onError:(error) => {
toast.error(`Failed to create workflow: ${error.message}`)
}
}))
}
12-2. Create 버튼 연결
src/app/features/workflows/components/workflows.tsx
- "New workflow" 버튼 클릭
- handleCreate 함수 실행
- createWorkflow.mutate() 호출
- 성공 시 → 생성된 워크플로우 페이지로 이동
- 실패 시 → 에러 처리 (구독 필요 모달 표시 예정)
'use client'
import { EntityContainer, EntityHeader } from "@/components/entity-components";
import { useCreateWorkflow, useSuspenseWorkflows } from "../hooks/use-workflows"
export const WorkflowsList = () => {
...
}
export const WorkflowsHeader = ({disabled} : {disabled?:boolean}) => {
const createWorkflow = useCreateWorkflow()
const handleCreate = () =>{
createWorkflow.mutate(undefined, {
onError:(error) => {
// TODO: open upgrade modeal
console.error(error)
}
})
}
return(
<>
<EntityHeader
title="Workflows"
description="Create and manage your workflows"
onNew={handleCreate}
newButtonLabel="New workflow"
disabled={disabled}
isCreating={createWorkflow.isPending}
/>
</>
)
}
export const WorkflowsContainer = ({
children
}:{
children:React.ReactNode
})=>{
...
}
버튼을 누르면 해당 아이디 페이지로 이동하고

workflows 목록에는 생성된 정보들이 쌓인다.

13 - 구독 전용 기능 보호
13-1. Upgrade Modal 컴포넌트
src/components/upgrade-modal.tsx
- "Cancel" 버튼: 모달 닫기
- "Upgrade Now" 버튼: Polar 결제 페이지로 이동
import { authClient } from "@/lib/auth-client";
import { AlertDialog, AlertDialogTitle, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogAction } from "./ui/alert-dialog";
interface UpgradeModalProps{
open:boolean;
onOpenChange: (open:boolean) => void
}
export const UpgradeModal = ({open, onOpenChange} : UpgradeModalProps) => {
return(
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Upgrade to Pro</AlertDialogTitle>
<AlertDialogDescription>
You need an active subscription to perform this action. upgrade to Pro to unlock all features.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={()=>authClient.checkout({slug:"pro"})}>
Upgrade Now
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
13-2. Upgrade Modal Hook
src/hooks/use-upgrade-modal.tsx
- handleError: tRPC 에러 확인
- FORBIDDEN 에러 → 모달 열기 (구독 필요)
- 다른 에러 → false 반환
- modal: 렌더링할 Modal 컴포넌트 제공
import { UpgradeModal } from "@/components/upgrade-modal";
import { TRPCClientError } from "@trpc/client";
import { useState } from "react"
export const useUpgradeModal = () => {
const [open, setOpen] = useState(false);
const handleError = (error:unknown) => {
if(error instanceof TRPCClientError){
if(error.data?.code === "FORBIDDEN"){
setOpen(true);
return true
}
}
return false;
};
const modal = <UpgradeModal open={open} onOpenChange={setOpen}/>
return { handleError, modal}
}
13-3. Workflows Header에 Modal 통합
src/app/features/workflows/components/workflows.tsx
'use client'
import { EntityContainer, EntityHeader } from "@/components/entity-components";
import { useCreateWorkflow, useSuspenseWorkflows } from "../hooks/use-workflows"
import { useUpgradeModal } from "@/hooks/use-upgrade-modal";
export const WorkflowsList = () => {
const workflows = useSuspenseWorkflows();
return(
<div className="flex-1 flex justify-center items-center">
<p>
{JSON.stringify(workflows.data, null, 2)}
</p>
</div>
)
}
export const WorkflowsHeader = ({disabled} : {disabled?:boolean}) => {
const createWorkflow = useCreateWorkflow()
const {handleError, modal} = useUpgradeModal()
const handleCreate = () =>{
createWorkflow.mutate(undefined, {
onSuccess: (data) => {
router.push(`/workflows/${data.id}`)
},
onError:(error) => {
handleError(error)
}
})
}
return(
<>
{modal}
<EntityHeader
title="Workflows"
description="Create and manage your workflows"
onNew={handleCreate}
newButtonLabel="New workflow"
disabled={disabled}
isCreating={createWorkflow.isPending}
/>
</>
)
}
export const WorkflowsContainer = ({
children
}:{
children:React.ReactNode
})=>{
return(
<EntityContainer
header={<WorkflowsHeader/>}
search={<></>}
pagination={<></>}
>
{children}
</EntityContainer>
)
}
13-4. Create API를 Premium Procedure로 변경
src/app/features/workflows/server/routers.ts
- create만 premiumProcedure로 변경. 조회/수정/삭제는 무료 사용자도 가능.
import z from 'zod'
import {generateSlug} from "random-word-slugs"
import prisma from "@/lib/db";
import { createTRPCRouter, premiumProcedure, protectedProcedure } from "@/trpc/init";
export const workflowsRouter = createTRPCRouter({
// create: protectedProcedure.mutation(({ctx}) => {
create: premiumProcedure.mutation(({ctx}) => {
return prisma.workflow.create({
data : {
// name: "TODO",
name: generateSlug(3),
userId: ctx.auth.user.id
}
})
}),
...
})
13-5. Hook 리팩토링 (불필요한 라우팅 제거)
src/app/features/workflows/hooks/use-workflows.ts
- 라우팅은 컴포넌트 레벨에서 처리
- 더 유연한 에러 핸들링 가능
export const useCreateWorkflow = () => {
// const router = useRouter()
const queryClient = useQueryClient();
const trpc = useTRPC();
return useMutation(trpc.workflows.create.mutationOptions({
onSuccess: (data) => {
toast.success(`Workflow "${data.name}" created` )
// router.push(`/workflows/${data.id}`)
queryClient.invalidateQueries(
trpc.workflows.getMany.queryOptions()
)
},
onError:(error) => {
toast.error(`Failed to create workflow: ${error.message}`)
}
}))
}
'Clone Coding' 카테고리의 다른 글
| N8N & Zapier - Workflows UI (1) | 2026.01.16 |
|---|---|
| N8N & Zapier - Workflows Pagination (0) | 2026.01.15 |
| N8N & Zapier - Workflows Crud / 1 (0) | 2026.01.12 |
| N8N & Zapier - Payments Setup (1) | 2026.01.09 |
| N8N & Zapier - Sidebar Layout (0) | 2026.01.07 |