728x90
본 프로젝트는 https://www.codewithantonio.com/ 의 강의를 바탕으로 진행하였습니다.
온라인 쇼핑몰 웹사이트를 구현하는 사이드 프로젝트를 진행하였습니다.
해당 개발은 Admin 사이트와 Store 사이트로 구분하여 개발됩니다.
기본 구현은 해당 강의를 바탕으로 진행하며, 강의를 따라가면서 몰랐던 개념을 정리합니다.
그 밖에 에러 및 주요 서비스 문제 해결 과정은 개인 개발 블로그에 남겼습니다.
Admin Page
Dashboard 초기 설정
app/(dashboard)/[storeId]/layout.tsx 파일을 다음과 같이 생성합니다.
import { auth } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import prismadb from "@/lib/prismadb";
export default async function DashboardLayout({
children,
params
}: {
children: React.ReactNode;
params: { storeId: string }
}) {
const { userId } = auth();
if(!userId) {
redirect('/sign-in');
}
const store = await prismadb.store.findFirst({
where: {
id: params.storeId,
userId
}
});
if (!store) {
redirect('/');
}
return (
<>
<div>This will be a Navbar</div>
{children}
</>
)
}
app/(dashboard)/[storeId]/(routes)/page.tsx 파일을 다음과 같이 생성합니다.
const DashboardPage = () => {
return (
<div>
This is a Dashboard!
</div>
);
}
export default DashboardPage;
app/(root)/layout.tsx 파일을 다음과 같이 생성합니다.
import { auth } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import prismadb from "@/lib/prismadb";
export default async function SetupLayout({
children
}: {
children: React.ReactNode;
}) {
const { userId } = auth();
if (!userId) {
redirect('/sign-in');
}
const store = await prismadb.store.findFirst({
where: {
userId
}
});
if (store) {
redirect(`/${store.id}`);
}
return (
<>
{children}
</>
);
}
app/(root)/(routes) 그룹을 만들어 page.tsx 파일을 (routes) 디렉토리에 넣어줍니다.
먼저, app/(root)/layout.tsx 에서 store 값이 존재하면 `/${store.id}` 링크로 redirect 합니다.
app/(dashboard)/[storeId]에서 중괄호로 둘러쌓인 storeId 는 app/(dashboard)/[storeId]/layout.tsx의
DashboardLayout 매개변수인 params의 값으로 동적 경로를 생성하는데 사용됩니다.
아래의 경로에서 [storeId] 가 저장된 params인 id 값으로 동적 경로를 구성하는데 사용되는 것을 확인할 수 있습니다.
아래 명령어를 통해 database를 초기화 한 후 다시 실행하면 어떤 화면으로 시작하는지 테스트 합니다.
npx prisma migrate reset
npx prisma generate
npx prisma db push
database에 저장된 store 값이 없으므로 아래와 같은 초기 화면으로 되돌아 온 것을 확인할 수 있습니다.
components/modals/store-modal.tsx 파일을 다음과 같이 수정합니다.
...
export const StoreModal = () => {
...
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
setLoading(true);
const response = await axios.post('/api/stores', values);
window.location.assign(`/${response.data.id}`)
} catch (error) {
toast.error("Something went wrong.");
} finally {
setLoading(false);
}
}
}
dashboard/[storeId]/(routes)/page.tsx 파일을 다음과 같이 수정합니다.
import prismadb from "@/lib/prismadb";
interface DashboardPageProps {
params: { storeId: string }
};
const DashboardPage: React.FC<DashboardPageProps> = async ({
params
}) => {
const store = await prismadb.store.findFirst({
where: {
id: params.storeId
}
});
return (
<div>
Active Store: {store?.name};
</div>
);
}
export default DashboardPage;
다음과 같이 저장된 store 이름이 정상적으로 출력되는 것을 확인할 수 있습니다.
Navigation bar 초기 설정
components/navbar.tsx를 다음과 같이 생성합니다.
import { UserButton } from "@clerk/nextjs";
import { MainNav } from "@/components/main-nav";
const Navbar = () => {
return (
<div className="border-b">
<div className="flex h-16 items-center px-4">
<div>
This will be a store switcher
</div>
<MainNav />
<div className="ml-auto flex items-center space-x-4">
<UserButton afterSignOutUrl="/" />
</div>
</div>
</div>
);
}
export default Navbar;
components/main-nav.tsx를 다음과 같이 생성합니다.
"use client";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { useParams, usePathname } from "next/navigation";
export function MainNav({
className,
...props
}: React.HTMLAttributes<HTMLElement>) {
const pathname = usePathname();
const params = useParams();
const routes = [
{
href: `/${params.storeId}/settings`,
label: 'Settings',
active: pathname === `/${params.storeId}/settings`,
},
];
return (
<nav
className={cn("flex items-center space-x-4 lg:space-x-6", className)}
>
{routes.map((route) => (
<Link
key={route.href}
href={route.href}
className={cn(
"text-sm font-medium transition-colors hover:text-primary",
route.active ? "text-black dark:text-white" : "text-muted-foreground"
)}
>
{route.label}
</Link>
))}
</nav>
)
}
Shadcn/ui 공식 문서 : https://ui.shadcn.com/docs/components/popover
shadcn-ui 의 popover, command 기능을 추가하면
components/ui 디렉토리에 각각의 해당 컴포넌트 파일이 생성된 것을 확인할 수 있습니다.
npx shadcn-ui@latest add popover
npx shadcn-ui@latest add command
main-nav.tsx 파일의 routes를 통해서 원하는 경로로 이동 할 수 있습니다.
components/store-switcher.tsx를 다음과 같이 생성합니다.
"use client";
import { Check, ChevronsUpDown, PlusCircle, Store as StoreIcon } from "lucide-react";
import { useState } from "react";
import { Store } from "@prisma/client"
import { useParams, useRouter } from "next/navigation";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useStoreModal } from "@/hooks/use-store-modal";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from "@/components/ui/command";
type PopoverTriggerProps = React.ComponentPropsWithoutRef<typeof PopoverTrigger>
interface StoreSwitcherProps extends PopoverTriggerProps {
items: Store[];
};
export default function StoreSwitcher({
className,
items = []
}: StoreSwitcherProps) {
const storeModal = useStoreModal();
const params = useParams();
const router = useRouter();
const formattedItems = items.map((item) => ({
label: item.name,
value: item.id
}));
const currentStore = formattedItems.find((item) => item.value === params.storeId);
const [open, setOpen] = useState(false);
const onStoreSelect = (store: { value: string, label: string }) => {
setOpen(false);
router.push(`/${store.value}`);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
aria-label="Select Store"
className={cn("w-[200px] justify-between", className)}
>
<StoreIcon className="mr-2 h-4 w-4" />
{currentStore?.label}
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50"/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandList>
<CommandInput placeholder="스토어 검색하기..."/>
<CommandEmpty>스토어를 찾을 수 없습니다.</CommandEmpty>
<CommandGroup heading="Stores">
{formattedItems.map((store) => (
<CommandItem
key={store.value}
onSelect={() => onStoreSelect(store)}
className="text-sm"
>
<StoreIcon className="mr-2 h-4 w-4"/>
{store.label}
<Check
className={cn(
"ml-auto h-4 w-4",
currentStore?.value === store.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
<CommandSeparator />
<CommandList>
<CommandGroup>
<CommandItem
onSelect={() => {
setOpen(false);
storeModal.onOpen();
}}
>
<PlusCircle className="mr-2 h-5 w-5" />
스토어 생성하기
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
components/navbar.tsx를 다음과 같이 수정합니다.
import { UserButton, auth } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import { MainNav } from "@/components/main-nav";
import StoreSwitcher from "@/components/store-switcher";
import prismadb from "@/lib/prismadb";
const Navbar = async () => {
const { userId } = auth();
if (!userId) {
redirect("/sign-in");
}
const stores = await prismadb.store.findMany({
where: {
userId,
},
});
return (
<div className="border-b">
<div className="flex h-16 items-center px-4">
<StoreSwitcher items={stores} />
<MainNav className="mx-6"/>
<div className="ml-auto flex items-center space-x-4">
<UserButton afterSignOutUrl="/" />
</div>
</div>
</div>
);
}
export default Navbar;
navbar.tsx에서 store 테이블을 통해 가져온 userId 들을 stores에 저장하여 StoreSwitcher 함수의 items로 저장합니다.
store-switcher.tsx에서 shadcn/ui의 command, popover 라이브러리를 사용해 store-switcher 기능을 구현합니다.
Settings 페이지 설정
Shadcn/ui 공식 문서 : https://ui.shadcn.com/docs/components/separator
shadcn-ui 의 seperator, alert, badge 기능을 추가하면
components/ui 디렉토리에 각각의 해당 컴포넌트 파일이 생성된 것을 확인할 수 있습니다.
npx shadcn-ui@latest add seperator
npx shadcn-ui@latest add alert
npx shadcn-ui@latest add badge
app/(dashboard)/[storeId]/(routes)/settings/page.tsx 파일을 다음과 같이 생성합니다.
import { auth } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import prismadb from "@/lib/prismadb";
import { SettingsForm } from "./components/settings-form";
interface SettingsPageProps {
params: {
storeId: string;
}
};
const SettingsPage: React.FC<SettingsPageProps> = async ({
params
}) => {
const { userId } = auth();
if(!userId) {
redirect("/sign-in");
}
const store = await prismadb.store.findFirst({
where: {
id: params.storeId,
userId
}
});
if (!store) {
redirect("/");
}
return (
<div className="flex-col">
<div className="flex-1 space-x-4 p-8 pt-6">
<SettingsForm initialData={store}/>
</div>
</div>
);
}
export default SettingsPage;
app/(dashboard)/[storeId]/(routes)/settings/components/setting-form.tsx 파일을 다음과 같이 생성합니다.
"use client";
import * as z from "zod";
import { useState } from "react";
import { Store } from "@prisma/client";
import { Trash } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Heading } from "@/components/ui/heading";
import { Separator } from "@/components/ui/separator";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
interface SettingsFormProps {
initialData: Store;
}
const formSchema = z.object({
name: z.string().min(1),
});
type SettingsFormValues = z.infer<typeof formSchema>;
export const SettingsForm: React.FC<SettingsFormProps> = ({
initialData
}) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const form = useForm<SettingsFormValues>({
resolver: zodResolver(formSchema),
defaultValues: initialData
});
const onSubmit = async (data: SettingsFormValues) => {
console.log(data);
};
return (
<>
<div className="flex items-center justify-between">
<Heading
title="Settings"
description="Manage store preference"
/>
<Button
disabled={loading}
variant="destructive"
size="icon"
onClick={() => setOpen(true)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
<Separator className="my-4"/>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full">
<div className="grid grid-cols-3 gap-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input disabled={loading} placeholder="Store name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button disabled={loading} className="ml-auto" type="submit">
Save Changes
</Button>
</form>
</Form>
</>
)
}
components/ui/heading.tsx 파일을 다음과 같이 생성합니다.
import React from "react";
interface HeadingProps {
title: string,
description: string,
}
export const Heading: React.FC<HeadingProps> = ({
title,
description
}) => {
return (
<div>
<h2 className="text-3xl font-bold tracking-tight">{title}</h2>
<p className="text-sm text-muted-foreground">
{description}
</p>
</div>
)
}
이제 스토어 삭제, 업데이트 기능이 올바르게 작동할 수 있도록,
app/api/stores/[storeId]/route.ts 파일을 다음과 같이 생성합니다.
import prismadb from "@/lib/prismadb";
import { auth } from "@clerk/nextjs";
import { NextResponse } from "next/server";
export async function PATCH (
req: Request,
{ params }: { params: { storeId: string } }
) {
try {
const { userId } = auth();
const body = await req.json();
const { name } = body;
if(!userId) {
return new NextResponse("Unauthenticated", { status: 401 });
}
if (!name) {
return new NextResponse("Name is required", { status: 400 });
}
if (!params.storeId) {
return new NextResponse("Store ID is required", { status: 400 });
}
const store = await prismadb.store.updateMany({
where: {
id: params.storeId,
userId
},
data: {
name
}
});
return NextResponse.json(store);
} catch (error) {
console.log('[STORE_PATCH]', error);
return new NextResponse("Internal Error", { status: 500 });
}
};
export async function DELETE (
req: Request,
{ params }: { params: { storeId: string } }
) {
try {
const { userId } = auth();
if(!userId) {
return new NextResponse("Unauthenticated", { status: 401 });
}
if (!params.storeId) {
return new NextResponse("Store ID is required", { status: 400 });
}
const store = await prismadb.store.deleteMany({
where: {
id: params.storeId,
userId
}
});
return NextResponse.json(store);
} catch (error) {
console.log('[STORE_DELETE]', error);
return new NextResponse("Internal Error", { status: 500 });
}
};
app/(dashboard)/[storeId]/(routes)/settings/components/setting-form.tsx 파일을 다음과 같이 수정합니다.
...
export const SettingsForm: React.FC<SettingsFormProps> = ({
initialData
}) => {
...
const onSubmit = async (data: SettingsFormValues) => {
try {
setLoading(true);
await axios.patch(`/api/stores/${params.storeId}`, data);
router.refresh();
toast.success("Store updated.");
} catch (error) {
toast.error("Something went wrong.");
} finally {
setLoading(false);
}
};
...
}
삭제 버튼에 필요한 모달을 위해 components/modals/alert-modal.tsx 파일을 다음과 같이 생성합니다.
"use client";
import { useEffect, useState } from "react";
import { StoreModal } from "./store-modal";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
loading: boolean;
}
export const AlertModal: React.FC<AlertModalProps> = ({
isOpen,
onClose,
onConfirm,
loading
}) => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return null;
}
return (
<>
<Modal
title="Are you sure?"
description="This action cannot be undone."
isOpen={isOpen}
onClose={onClose}
>
<div className="pt-6 space-x-2 flex items-center justify-end w-full">
<Button disabled={loading} variant="outline">
Cancel
</Button>
<Button disabled={loading} variant="destructive" onClick={onConfirm}>
Continue
</Button>
</div>
</Modal>
</>
);
}
app/(dashboard)/[storeId]/(routes)/settings/components/setting-form.tsx 파일을 다음과 같이 수정합니다.
...
export const SettingsForm: React.FC<SettingsFormProps> = ({
initialData
}) => {
...
const onSubmit = async (data: SettingsFormValues) => {
try {
setLoading(true);
await axios.patch(`/api/stores/${params.storeId}`, data);
router.refresh();
toast.success("Store updated.");
} catch (error) {
toast.error("Something went wrong.");
} finally {
setLoading(false);
}
};
const onDelete = async () => {
try {
setLoading(true);
await axios.delete(`/api/stores/${params.storeId}`)
router.refresh();
router.push("/");
toast.success("Store deleted.");
} catch (error) {
toast.error("Make sure removed all products and categories first.");
} finally {
setLoading(false);
setOpen(false);
}
}
return (
<>
<AlertModal
isOpen={open}
onClose={() => setOpen(false)}
onConfirm={onDelete}
loading={loading}
/>
...
</>
);
}
코드를 올바르게 작성했다면 아래와 같이 수정, 삭제 기능이 올바르게 동작하는 화면을 확인할 수 있습니다.
components/main-nav.tsx 파일을 다음과 같이 수정합니다.
...
export function MainNav({
className,
...props
}: React.HTMLAttributes<HTMLElement>) {
...
const routes = [
{
href: `/${params.storeId}`,
label: 'Overview',
active: pathname === `/${params.storeId}`,
},
...
];
...
}
components/ui/api-alert.tsx 파일을 다음과 같이 생성합니다.
"use client";
import toast from "react-hot-toast";
import { Copy, Server } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge, BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
interface ApiAlertProps {
title: string;
description: string;
variant: "public" | "admin";
};
const textMap: Record<ApiAlertProps["variant"], string> = {
public: "Public",
admin: "Admin"
};
const variantMap: Record<ApiAlertProps["variant"], BadgeProps["variant"]> = {
public: "secondary",
admin: "destructive"
};
export const ApiAlert: React.FC<ApiAlertProps> = ({
title,
description,
variant = "public",
}) => {
const onCopy = () => {
navigator.clipboard.writeText(description);
toast.success("API Route copied to the clipboard.");
}
return (
<Alert>
<Server className="h-4 w-4"/>
<AlertTitle className="flex items-center gap-x-2">
{title}
<Badge variant={variantMap[variant]}>
{textMap[variant]}
</Badge>
</AlertTitle>
<AlertDescription className="mt-4 flex items-center justify-between">
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
{description}
</code>
<Button variant="outline" size="icon" onClick={onCopy}>
<Copy className="h-4 w-4"/>
</Button>
</AlertDescription>
</Alert>
);
}
hooks/use-origin.tsx 파일을 다음과 같이 생성합니다.
import { useState, useEffect } from "react"
export const useOrigin = () => {
const [mounted, setMounted] = useState(false);
const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : '';
useEffect(() => {
setMounted(true);
}, [])
if (!mounted) {
return '';
}
return origin;
}
app/(dashboard)/[storeId]/(routes)/settings/components/settings-form.tsx 파일을 다음과 같이 수정합니다.
export const SettingsForm: React.FC<SettingsFormProps> = ({
initialData
}) => {
...
return (
<>
...
<Separator className="my-4"/>
<ApiAlert
title="NEXT_PUBLIC_API_URL"
description={`${origin}/api/${params.storeId}`}
variant="public"
/>
</>
);
}
코드를 올바르게 작성했다면 아래와 같이 API 값이 올바르게 출력된 화면을 확인할 수 있습니다.