diff --git a/.gitignore b/.gitignore index bfdd87b0a..a355a3402 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ # dependencies node_modules +# build +.next +dist + # misc .DS_Store - -# env -.env* \ No newline at end of file diff --git a/apps/cs/.env.local b/apps/cs/.env.local new file mode 100644 index 000000000..0648024ff --- /dev/null +++ b/apps/cs/.env.local @@ -0,0 +1,2 @@ +NEXT_PUBLIC_PORT=3000 +NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000 diff --git a/apps/cs/.gitignore b/apps/cs/.gitignore index 5ef6a5207..5340dd9a1 100644 --- a/apps/cs/.gitignore +++ b/apps/cs/.gitignore @@ -30,9 +30,6 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env* - # vercel .vercel diff --git a/apps/cs/app/(with-base-layout)/layout.tsx b/apps/cs/app/(with-base-layout)/layout.tsx new file mode 100644 index 000000000..cbcaee847 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/layout.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { BaseLayout } from "@common/components/ui"; + +export default function WithBaseLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/apps/cs/app/site/page.tsx b/apps/cs/app/(with-base-layout)/site/page.tsx similarity index 100% rename from apps/cs/app/site/page.tsx rename to apps/cs/app/(with-base-layout)/site/page.tsx diff --git a/apps/cs/app/(with-base-layout)/site/register/[id]/page.tsx b/apps/cs/app/(with-base-layout)/site/register/[id]/page.tsx new file mode 100644 index 000000000..685d77172 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/site/register/[id]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/site"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/apps/cs/app/(with-base-layout)/site/register/page.tsx b/apps/cs/app/(with-base-layout)/site/register/page.tsx new file mode 100644 index 000000000..01e25cc43 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/site/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/site"; +export default CreatePage; diff --git a/apps/cs/app/api/proxy/[...url]/route.ts b/apps/cs/app/api/proxy/[...url]/route.ts index 5397e5fe6..d3092c5c1 100644 --- a/apps/cs/app/api/proxy/[...url]/route.ts +++ b/apps/cs/app/api/proxy/[...url]/route.ts @@ -5,7 +5,7 @@ import { REFRESH_TOKEN, SERVER_API_URL, SITE_ID, -} from "@/shared/config/env"; +} from "@/shared/config"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/apps/cs/app/site/register/page.tsx b/apps/cs/app/site/register/page.tsx deleted file mode 100644 index a11d0b60c..000000000 --- a/apps/cs/app/site/register/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { RegisterPage } from "@/pages/site"; -export default RegisterPage; diff --git a/apps/cs/middleware.ts b/apps/cs/middleware.ts new file mode 100644 index 000000000..e7b03dddc --- /dev/null +++ b/apps/cs/middleware.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; + +const serverApiUrl = + process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000"; + +export function middleware(req: NextRequest) { + const pathname = req.nextUrl.pathname; + if (pathname.startsWith("/server")) { + return NextResponse.rewrite( + `${serverApiUrl}${pathname.replace("/server", "")}`, + ); + } + + return NextResponse.next(); +} + +export const config = { + matcher: "/server/:path*", +}; diff --git a/apps/cs/next.config.ts b/apps/cs/next.config.ts index fd2a3a156..7c1a12675 100644 --- a/apps/cs/next.config.ts +++ b/apps/cs/next.config.ts @@ -1,9 +1,6 @@ import type { NextConfig } from "next"; import webpack from "webpack"; -const serverApiUrl = - process.env.NEXT_PUBLIC_SERVER_API_URL || "http://localhost:3000"; - const nextConfig: NextConfig = { webpack: (config) => { config.plugins.push( @@ -30,20 +27,11 @@ const nextConfig: NextConfig = { "import.meta.env.VITE_AUTH_USER_ID": JSON.stringify( process.env.NEXT_PUBLIC_AUTH_USER_ID, ), - "process.env.SITE_ID": JSON.stringify(process.env.NEXT_PUBLIC_SITE_ID), }), ); return config; }, - async rewrites() { - return [ - { - source: "/server/:path*", - destination: `${serverApiUrl}/:path*`, - }, - ]; - }, }; export default nextConfig; diff --git a/apps/cs/package.json b/apps/cs/package.json index 1797b4828..4b400a285 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -11,14 +11,22 @@ "dependencies": { "@common/assets": "workspace:*", "@common/components": "workspace:*", + "@frontend-opensource/use-react-hooks": "^1.1.4", + "@hookform/resolvers": "^4.1.3", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.67.1", "@tanstack/react-table": "^8.21.2", "axios": "^1.8.1", "chart.js": "^4.4.8", + "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "next": "15.1.7", "react-chartjs-2": "^5.3.0", - "tailwindcss-animate": "^1.0.7" + "react-hook-form": "^7.54.2", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.2", + "zustand": "^5.0.3" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/apps/cs/src/app/index.tsx b/apps/cs/src/app/index.tsx index 672023d15..d1d5e56e1 100644 --- a/apps/cs/src/app/index.tsx +++ b/apps/cs/src/app/index.tsx @@ -1,16 +1,12 @@ import "./styles/base.css"; import "./styles/components.css"; -import { AuthProvider, CookieProvider, QueryProvider } from "@/shared/provider"; +import { AppProvider } from "./providers"; const App = ({ children }: Readonly<{ children: React.ReactNode }>) => { return ( - - - {children} - - + {children} ); diff --git a/apps/cs/src/app/providers/app-provider.tsx b/apps/cs/src/app/providers/app-provider.tsx new file mode 100644 index 000000000..cf477dcc0 --- /dev/null +++ b/apps/cs/src/app/providers/app-provider.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; +import { AuthWrapper } from "@/entities/auth"; +import { QueryProvider, StoreProvider } from "@/shared/providers"; + +const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { + return ( + + + + {children} + + + + ); +}; + +export { AppProvider }; diff --git a/apps/cs/src/app/providers/index.ts b/apps/cs/src/app/providers/index.ts new file mode 100644 index 000000000..15f99b7fd --- /dev/null +++ b/apps/cs/src/app/providers/index.ts @@ -0,0 +1 @@ +export { AppProvider } from "./app-provider"; diff --git a/apps/cs/src/app/styles/components.css b/apps/cs/src/app/styles/components.css index 4d5caaf96..ec137cf25 100644 --- a/apps/cs/src/app/styles/components.css +++ b/apps/cs/src/app/styles/components.css @@ -5,7 +5,7 @@ @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; } .card-border { - @apply shadow-blur border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)]; + @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; } .card-header { @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; @@ -13,4 +13,13 @@ .card-footer { @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; } + + [data-mode="dark"] { + .card-border { + @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; + } + .card-header { + @apply border-b-[#3f4042]; + } + } } diff --git a/apps/cs/src/entities/auth/login/api/login-service.ts b/apps/cs/src/entities/auth/api/login-service.ts similarity index 78% rename from apps/cs/src/entities/auth/login/api/login-service.ts rename to apps/cs/src/entities/auth/api/login-service.ts index 722470184..c1968fe36 100644 --- a/apps/cs/src/entities/auth/login/api/login-service.ts +++ b/apps/cs/src/entities/auth/api/login-service.ts @@ -1,6 +1,11 @@ import { AxiosError } from "axios"; -import { ENDPOINT, JWT_EXPIRATION_TIME } from "@/shared/config/api"; -import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env"; +import { + ENDPOINT, + JWT_EXPIRATION_TIME, + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, +} from "@/shared/config"; import type { LoginService, ILogin } from "../model/login-interface"; import { tokenManager } from "../lib/token-manager"; @@ -31,7 +36,7 @@ export class LoginApiService implements LoginService { const tokenId = data[AUTH_USER_ID]; if (!accessToken || !tokenId) { - throw new Error("[login] Missing required authentication tokens"); + throw new Error("Missing required authentication tokens"); } tokenManager.setToken(CLAIM_NAME, accessToken); @@ -42,11 +47,9 @@ export class LoginApiService implements LoginService { ); } catch (error) { if (error instanceof AxiosError) { - throw new Error( - `Login failed: ${error.response?.data?.message || error.message}`, - ); + throw new Error(`Login failed: ${error.response?.data?.message}`); } - throw error; + throw new Error(`Login failed: ${error}`); } } @@ -62,22 +65,19 @@ export class LoginApiService implements LoginService { const tokenId = data[AUTH_USER_ID]; if (!accessToken || !tokenId) { - throw new Error( - "[silentRefresh] Missing required authentication tokens", - ); + throw new Error("Missing required authentication tokens"); } tokenManager.setToken(CLAIM_NAME, accessToken); tokenManager.setToken(AUTH_USER_ID, tokenId); } catch (error) { - console.warn("Token has expired, logging out."); tokenManager.clearAuthToken(); if (error instanceof AxiosError) { throw new Error( - `Silent refresh failed: ${error.response?.data?.message || error.message}`, + `Silent refresh failed: ${error.response?.data?.message}`, ); } - throw error; + throw new Error(`Silent refresh failed: ${error}`); } } } diff --git a/apps/cs/src/entities/auth/index.ts b/apps/cs/src/entities/auth/index.ts new file mode 100644 index 000000000..dc3fb96c9 --- /dev/null +++ b/apps/cs/src/entities/auth/index.ts @@ -0,0 +1,2 @@ +export { LoginApi } from "./api/login-service"; +export { AuthWrapper } from "./ui/auth-wrapper"; diff --git a/apps/cs/src/entities/auth/login/lib/token-manager.ts b/apps/cs/src/entities/auth/lib/token-manager.ts similarity index 94% rename from apps/cs/src/entities/auth/login/lib/token-manager.ts rename to apps/cs/src/entities/auth/lib/token-manager.ts index 4429c7d1b..0e761486a 100644 --- a/apps/cs/src/entities/auth/login/lib/token-manager.ts +++ b/apps/cs/src/entities/auth/lib/token-manager.ts @@ -1,5 +1,5 @@ -import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env"; -import { getCookie, setCookie, removeCookie } from "@/shared/lib/cookie"; +import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; +import { getCookie, setCookie, removeCookie } from "@/shared/lib"; export class TokenManager { private static instance: TokenManager; diff --git a/apps/cs/src/entities/auth/login/index.ts b/apps/cs/src/entities/auth/login/index.ts deleted file mode 100644 index 8c7981ac9..000000000 --- a/apps/cs/src/entities/auth/login/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LoginApi } from "./api/login-service"; diff --git a/apps/cs/src/entities/auth/login/model/login-interface.ts b/apps/cs/src/entities/auth/model/login-interface.ts similarity index 100% rename from apps/cs/src/entities/auth/login/model/login-interface.ts rename to apps/cs/src/entities/auth/model/login-interface.ts diff --git a/apps/cs/src/entities/auth/ui/auth-wrapper.tsx b/apps/cs/src/entities/auth/ui/auth-wrapper.tsx new file mode 100644 index 000000000..ba175e697 --- /dev/null +++ b/apps/cs/src/entities/auth/ui/auth-wrapper.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { client } from "@/shared/api"; +import { ENDPOINT, AUTH_USER_ID } from "@/shared/config"; +import { getCookie, useUserStore } from "@/shared/lib"; +import type { IUserInfo } from "@/shared/store"; +import { LoginApi } from "../api/login-service"; + +const AuthWrapper = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); + const { setUserInfo } = useUserStore((state) => state); + + useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.USERS], + queryFn: async () => { + try { + const userId = getCookie(AUTH_USER_ID); + + if (!userId) { + throw new Error("Missing required authentication tokens"); + } + + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/${userId}`, + ); + setUserInfo(response.data); + return response; + } catch (error) { + console.error(error); + try { + await LoginApi.silentRefresh(); + } catch (refreshError) { + console.error("Token refresh failed:", refreshError); + setUserInfo(null); + router.push("/auth/login"); + } + } + }, + }); + + return <>{children}; +}; + +export { AuthWrapper }; diff --git a/apps/cs/src/entities/code/api/code-service.ts b/apps/cs/src/entities/code/api/code-service.ts new file mode 100644 index 000000000..0d1055f6a --- /dev/null +++ b/apps/cs/src/entities/code/api/code-service.ts @@ -0,0 +1,14 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { CodeService, ICode } from "../model/code-interface"; + +export class CodeApiService implements CodeService { + public async getCode(upCd: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`, + ); + return response.data; + } +} + +export const CodeApi = new CodeApiService(); diff --git a/apps/cs/src/entities/code/index.ts b/apps/cs/src/entities/code/index.ts new file mode 100644 index 000000000..0b6633f4b --- /dev/null +++ b/apps/cs/src/entities/code/index.ts @@ -0,0 +1 @@ +export { CodeSelect } from "./ui/code-select"; diff --git a/apps/cs/src/entities/code/model/code-interface.ts b/apps/cs/src/entities/code/model/code-interface.ts new file mode 100644 index 000000000..0d066e6da --- /dev/null +++ b/apps/cs/src/entities/code/model/code-interface.ts @@ -0,0 +1,9 @@ +export interface ICode { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; +} + +export interface CodeService { + getCode(upCd: string): Promise; +} diff --git a/apps/cs/src/entities/code/ui/code-select.tsx b/apps/cs/src/entities/code/ui/code-select.tsx new file mode 100644 index 000000000..00b3157aa --- /dev/null +++ b/apps/cs/src/entities/code/ui/code-select.tsx @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { ENDPOINT } from "@/shared/config"; +import { CodeApi } from "../api/code-service"; + +type CodeSelectProps = { + upCd: string; + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; +}; + +function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) { + const { data: code } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd], + queryFn: () => CodeApi.getCode(upCd), + enabled: !!upCd, + }); + + const defaultValueText = + code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? "선택"; + + return ( + + ); +} + +export { CodeSelect }; diff --git a/apps/cs/src/entities/site/api/site-service.ts b/apps/cs/src/entities/site/api/site-service.ts deleted file mode 100644 index 89ba82350..000000000 --- a/apps/cs/src/entities/site/api/site-service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { client } from "@/shared/api/client"; -import { ENDPOINT } from "@/shared/config/api"; -import type { - ISiteQuery, - ISiteInfo, - SiteService, -} from "../model/site-interface"; - -export class SiteApiService implements SiteService { - public async siteInfo(query: ISiteQuery): Promise { - const response = await client.get( - `${ENDPOINT.CMS_SERVICE.SITES}`, - { params: query }, - ); - - return response.data; - } -} - -export const SiteApi = new SiteApiService(); diff --git a/apps/cs/src/entities/site/index.ts b/apps/cs/src/entities/site/index.ts deleted file mode 100644 index 92ccd0ff7..000000000 --- a/apps/cs/src/entities/site/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SiteApi } from "./api/site-service"; diff --git a/apps/cs/src/entities/site/model/site-interface.ts b/apps/cs/src/entities/site/model/site-interface.ts deleted file mode 100644 index 8ba2da298..000000000 --- a/apps/cs/src/entities/site/model/site-interface.ts +++ /dev/null @@ -1,49 +0,0 @@ -export interface ISiteQuery { - siteNm: string; - siteKndCd: string; - useYn: string; - page: number; - size: number; - sort: string[]; -} - -export interface ISite { - siteId: string; - siteNm: string; - siteKindCode: string; - bscSiteYn: string; - useYn: string; -} - -export interface ISiteInfo { - totalPages: number; - totalElements: number; - number: number; - sort: { - empty: boolean; - sorted: boolean; - unsorted: boolean; - }; - size: number; - content: ISite[]; - pageable: { - sort: { - empty: boolean; - sorted: boolean; - unsorted: boolean; - }; - offset: number; - pageNumber: number; - pageSize: number; - paged: boolean; - unpaged: boolean; - }; - numberOfElements: number; - first: boolean; - last: boolean; - empty: boolean; -} - -export interface SiteService { - siteInfo(query: ISiteQuery): Promise; -} diff --git a/apps/cs/src/entities/user/api/user-service.ts b/apps/cs/src/entities/user/api/user-service.ts deleted file mode 100644 index 41319bc1f..000000000 --- a/apps/cs/src/entities/user/api/user-service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { client } from "@/shared/api/client"; -import { ENDPOINT } from "@/shared/config/api"; -import type { IUser, IUserInfo, UserService } from "../model/user-interface"; - -export class UsersApiService implements UserService { - public async userInfo(payload: IUser): Promise { - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/${payload.userUniqId}`, - ); - - return response.data; - } -} - -export const UserApi = new UsersApiService(); diff --git a/apps/cs/src/entities/user/index.ts b/apps/cs/src/entities/user/index.ts deleted file mode 100644 index 3076aa261..000000000 --- a/apps/cs/src/entities/user/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useUser } from "./lib/use-user"; diff --git a/apps/cs/src/entities/user/lib/use-user.ts b/apps/cs/src/entities/user/lib/use-user.ts deleted file mode 100644 index 97cd2f034..000000000 --- a/apps/cs/src/entities/user/lib/use-user.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { ENDPOINT } from "@/shared/config/api"; -import { AUTH_USER_ID } from "@/shared/config/env"; -import { useCookie } from "@/shared/provider"; -import { UserApi } from "../api/user-service"; - -const useUser = () => { - const { getCookie } = useCookie(); - const { data } = useQuery({ - queryKey: [ENDPOINT.USER_SERVICE.USERS], - queryFn: () => - UserApi.userInfo({ - userUniqId: getCookie(AUTH_USER_ID) ?? "", - }), - }); - - return { userInfo: data }; -}; - -export { useUser }; diff --git a/apps/cs/src/pages/auth/login/ui/login-page.tsx b/apps/cs/src/pages/auth/login/ui/login-page.tsx index c8eb1e2f9..d5e12966a 100644 --- a/apps/cs/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cs/src/pages/auth/login/ui/login-page.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import { images } from "@common/assets"; -import { LoginApi } from "@/entities/auth/login"; +import { LoginApi } from "@/entities/auth"; import "./login.css"; function LoginPage() { diff --git a/apps/cs/src/pages/site/api/site-service.ts b/apps/cs/src/pages/site/api/site-service.ts new file mode 100644 index 000000000..22363de68 --- /dev/null +++ b/apps/cs/src/pages/site/api/site-service.ts @@ -0,0 +1,54 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + siteFormSchema, + type ISiteInfo, + type SiteService, + type ISiteDetail, + type ISiteParams, + type SiteFormData, +} from "../model/site-interface"; + +export class SiteApiService implements SiteService { + public async siteInfo(params: ISiteParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.SITES}`, + { params }, + ); + return response.data; + } + + public async siteDetail(siteId: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`, + ); + return response.data; + } + + public async createSite(data: SiteFormData): Promise { + const validatedData = siteFormSchema.parse(data); + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.SITES}`, + validatedData, + ); + return response.data; + } + + public async modifySite(siteId: string, data: SiteFormData): Promise { + const validatedData = siteFormSchema.parse(data); + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`, + validatedData, + ); + return response.data; + } + + public async deleteSite(siteId: string): Promise { + const response = await client.delete( + `${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`, + ); + return response.data; + } +} + +export const SiteApi = new SiteApiService(); diff --git a/apps/cs/src/pages/site/index.ts b/apps/cs/src/pages/site/index.ts index 22c7fef7b..40c65cf04 100644 --- a/apps/cs/src/pages/site/index.ts +++ b/apps/cs/src/pages/site/index.ts @@ -1,2 +1,3 @@ -export { RegisterPage } from "./ui/register-page"; +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; export { SitePage } from "./ui/site-page"; diff --git a/apps/cs/src/pages/site/model/site-interface.ts b/apps/cs/src/pages/site/model/site-interface.ts new file mode 100644 index 000000000..4d03e2c8b --- /dev/null +++ b/apps/cs/src/pages/site/model/site-interface.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +export interface ISiteParams { + siteNm: string; + siteKndCd: string; + useYn: string; + page: number; + size: number; + sort: string[]; +} + +export interface ISite { + siteId: string; + siteNm: string; + siteKindCode: string; + bscSiteYn: string; + siteAddr: string; + useYn: string; + regDt: string; + rgTrNm: string; +} + +export interface ISiteInfo { + totalPages: number; + totalElements: number; + number: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + size: number; + content: ISite[]; + pageable: { + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; + }; + numberOfElements: number; + first: boolean; + last: boolean; + empty: boolean; +} + +export interface ISiteDetail extends SiteFormData { + siteId: string; + siteKindNm: string; + basicSiteYn: string; + faxNumber: string; + regDt: string; + rgTr: string; + mdfcnDt: string; + mdFr: string; + rgTrNm: string; + mdFrNm: string; +} + +export const siteFormSchema = z.object({ + siteId: z.string().max(2).nonempty(), + siteNm: z.string().max(100).nonempty(), + siteExpln: z.string().max(1000).optional(), + siteKndCd: z.string().max(10).nonempty(), + siteAddr: z.string().max(2000).optional(), + faxNo: z.string().max(100).optional(), + telNo: z.string().max(1000).optional(), + lwndCn: z.string().max(2000).optional(), + bscSiteYn: z.string(), + bkmkIcon: z.string().max(100).optional(), + useYn: z.string(), + siteSkn: z.string().max(10).optional(), +}); + +export type SiteFormData = z.infer; + +export interface SiteService { + siteInfo(query: ISiteParams): Promise; + siteDetail(siteId: string): Promise; + modifySite(siteId: string, data: SiteFormData): Promise; + createSite(data: SiteFormData): Promise; +} diff --git a/apps/cs/src/pages/site/model/__mocks__.tsx b/apps/cs/src/pages/site/model/table-columns.tsx similarity index 69% rename from apps/cs/src/pages/site/model/__mocks__.tsx rename to apps/cs/src/pages/site/model/table-columns.tsx index 2b55da7d8..35c084896 100644 --- a/apps/cs/src/pages/site/model/__mocks__.tsx +++ b/apps/cs/src/pages/site/model/table-columns.tsx @@ -1,5 +1,6 @@ +import dayjs from "dayjs"; import { createColumnHelper } from "@tanstack/react-table"; -import type { ISite } from "@/entities/site/model/site-interface"; +import type { ISite } from "./site-interface"; const columnHelper = createColumnHelper(); @@ -16,8 +17,8 @@ const columns = [ id: "siteNm", header: "사이트명", }), - columnHelper.accessor("siteKindCode", { - id: "unknown1", + columnHelper.accessor("siteAddr", { + id: "siteAddr", header: "대표 URL", }), columnHelper.accessor("bscSiteYn", { @@ -30,13 +31,17 @@ const columns = [ header: "사용 여부", cell: (info) => (info.getValue() ? "Y" : "N"), }), - columnHelper.accessor("siteId", { - id: "unknown2", + columnHelper.accessor("rgTrNm", { + id: "rgTrNm", header: "등록자", }), - columnHelper.accessor("siteId", { - id: "ununknown3", + columnHelper.accessor("regDt", { + id: "regDt", header: "등록일", + cell: (info) => { + const date = dayjs(info.getValue()); + return date.format("YYYY-MM-DD"); + }, }), ]; diff --git a/apps/cs/src/pages/site/ui/create-page.tsx b/apps/cs/src/pages/site/ui/create-page.tsx new file mode 100644 index 000000000..a79c16022 --- /dev/null +++ b/apps/cs/src/pages/site/ui/create-page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { useMutation } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { SiteApi } from "../api/site-service"; +import { siteFormSchema, type SiteFormData } from "../model/site-interface"; +import { SitePageLayout } from "./layout"; +import { SiteForm } from "./site-form"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, +} from "@common/components/ui"; + +function CreatePage() { + const router = useRouter(); + const { mutateAsync: createSite } = useMutation({ + mutationFn: (data: SiteFormData) => SiteApi.createSite(data), + onSuccess: () => { + setIsCreateSuccess(true); + }, + }); + + const [isCreateSuccess, setIsCreateSuccess] = useState(false); + + const form = useForm({ + resolver: zodResolver(siteFormSchema), + }); + + return ( + + + router.back()}> + +
+

등록되었습니다.

+
+ + + + + +
+
+
+ ); +} + +export { CreatePage }; diff --git a/apps/cs/src/pages/site/ui/domain-management-dialog.tsx b/apps/cs/src/pages/site/ui/domain-management-dialog.tsx new file mode 100644 index 000000000..c675f92d9 --- /dev/null +++ b/apps/cs/src/pages/site/ui/domain-management-dialog.tsx @@ -0,0 +1,210 @@ +import { useState } from "react"; +import { + Dialog, + DialogTrigger, + Button, + DialogContent, + DialogHeader, + DialogTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Input, + Checkbox, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + DialogFooter, + DialogClose, +} from "@common/components"; + +export type Domain = { + id: number; + domainUrl: string; + isRepresentative: boolean; + isActive: "true" | "false"; +}; + +type DomainManagementDialogProps = { + triggerDisabled?: boolean; + handleSave: (rows: Domain[]) => void; +}; + +function DomainManagementDialog({ + triggerDisabled, + handleSave, +}: DomainManagementDialogProps) { + const [rows, setRows] = useState([]); + const [rowSelection, setRowSelection] = useState([]); + + const onSave = () => { + handleSave(rows); + }; + + const handleAddDomain = () => { + const newDomain: Domain = { + id: Date.now(), + domainUrl: "", + isRepresentative: rows.length === 0, + isActive: "true", + }; + setRows([...rows, newDomain]); + }; + + const handleDeleteDomain = () => { + const filteredRows = rows.filter((row) => !rowSelection.includes(row.id)); + const hasRepresentative = filteredRows.some((row) => row.isRepresentative); + + let updatedRows = filteredRows; + if (!hasRepresentative && filteredRows.length > 0) { + updatedRows = filteredRows.map((row, index) => + index === 0 ? { ...row, isRepresentative: true } : row, + ); + } + + setRows(updatedRows); + setRowSelection([]); + }; + + const handleDomainUrlChange = (id: number, value: string) => { + setRows( + rows.map((row) => (row.id === id ? { ...row, domainUrl: value } : row)), + ); + }; + + const handleRepresentativeChange = (id: number) => { + setRows( + rows.map((row) => ({ + ...row, + isRepresentative: row.id === id, + })), + ); + }; + + const handleActiveChange = (id: number, value: "true" | "false") => { + setRows( + rows.map((row) => (row.id === id ? { ...row, isActive: value } : row)), + ); + }; + + const toggleRowSelection = (id: number) => { + setRowSelection((prev) => { + if (prev.includes(id)) { + return prev.filter((item) => item !== id); + } else { + return [...prev, id]; + } + }); + }; + + return ( + + + + + + + 도메인 관리 + +
+ + +
+
+ + + + + + + + + + + NO + 선택 + 도메인URL + 대표URL + 사용여부 + + + + {rows.map((row, idx) => ( + + {idx + 1} + + toggleRowSelection(row.id)} + className="cursor-pointer" + /> + + + + handleDomainUrlChange(row.id, e.target.value) + } + className="!text-[1.3rem]" + /> + + + handleRepresentativeChange(row.id)} + className="cursor-pointer" + /> + + + + + + ))} + +
+
+ + + + + + + + +
+
+ ); +} + +export { DomainManagementDialog }; diff --git a/apps/cs/src/pages/site/ui/ip-management-dialog.tsx b/apps/cs/src/pages/site/ui/ip-management-dialog.tsx index 04b71afc7..06d9638b3 100644 --- a/apps/cs/src/pages/site/ui/ip-management-dialog.tsx +++ b/apps/cs/src/pages/site/ui/ip-management-dialog.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useState } from "react"; import { Dialog, @@ -18,6 +16,8 @@ import { RadioGroup, RadioGroupItem, Checkbox, + DialogFooter, + DialogClose, } from "@common/components"; type IP = { @@ -26,11 +26,15 @@ type IP = { allowed: boolean; }; -function IPManagementDialog() { +type IPManagementDialogProps = { + triggerDisabled?: boolean; +}; + +function IPManagementDialog({ triggerDisabled }: IPManagementDialogProps) { const [rows, setRows] = useState([]); const [rowSelection, setRowSelection] = useState([]); - const handleSave = () => {}; + const onSave = () => {}; const handleAddIP = () => { const newIP: IP = { @@ -72,9 +76,9 @@ function IPManagementDialog() { return ( - + - + IP 관리 @@ -82,9 +86,6 @@ function IPManagementDialog() { - @@ -98,7 +99,7 @@ function IPManagementDialog() {
- +
@@ -156,6 +157,18 @@ function IPManagementDialog() {
+ + + + + + + +
); diff --git a/apps/cs/src/pages/site/ui/layout.tsx b/apps/cs/src/pages/site/ui/layout.tsx index a5d85dee3..3fec47e17 100644 --- a/apps/cs/src/pages/site/ui/layout.tsx +++ b/apps/cs/src/pages/site/ui/layout.tsx @@ -1,15 +1,11 @@ -import { BaseLayout } from "@common/components"; - function SitePageLayout({ children }: { children: React.ReactNode }) { return ( - -
-

- 사이트 관리 -

- {children} -
-
+
+

+ 사이트 관리 +

+ {children} +
); } diff --git a/apps/cs/src/pages/site/ui/modify-page.tsx b/apps/cs/src/pages/site/ui/modify-page.tsx new file mode 100644 index 000000000..8c3bbd093 --- /dev/null +++ b/apps/cs/src/pages/site/ui/modify-page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ENDPOINT } from "@/shared/config"; +import { SiteApi } from "../api/site-service"; +import { + siteFormSchema, + type ISiteDetail, + type SiteFormData, +} from "../model/site-interface"; +import { SitePageLayout } from "./layout"; +import { SiteForm } from "./site-form"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, +} from "@common/components/ui"; + +function ModifyPage({ siteId }: { siteId: string }) { + const isModifyMode = !!siteId; + + const router = useRouter(); + const { data: siteDetail } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId], + queryFn: () => SiteApi.siteDetail(siteId), + select: (data) => ({ + ...data, + faxNo: data.faxNumber, + bscSiteYn: data.basicSiteYn, + }), + enabled: isModifyMode, + }); + + const { mutateAsync: modifySite } = useMutation({ + mutationFn: (data: SiteFormData) => SiteApi.modifySite(siteId, data), + onSuccess: () => { + setMessage("수정되었습니다."); + }, + }); + + const { mutateAsync: deleteSite } = useMutation({ + mutationFn: () => SiteApi.deleteSite(siteId), + onSuccess: () => { + setMessage("삭제되었습니다."); + }, + }); + const [message, setMessage] = useState(""); + + const form = useForm({ + resolver: zodResolver(siteFormSchema), + }); + + useEffect(() => { + if (!siteDetail) return; + + const keys = Object.keys(siteFormSchema.shape); + for (const key of keys) { + form.setValue( + key as keyof SiteFormData, + siteDetail[key as keyof ISiteDetail] ?? "", + ); + } + }, [siteDetail]); + + return ( + + + router.back()}> + +
+

{message}

+
+ + + + + +
+
+
+ ); +} + +export { ModifyPage }; diff --git a/apps/cs/src/pages/site/ui/register-page.tsx b/apps/cs/src/pages/site/ui/register-page.tsx deleted file mode 100644 index 3c35ae8e0..000000000 --- a/apps/cs/src/pages/site/ui/register-page.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client"; - -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Input, - Label, - RadioGroup, - RadioGroupItem, - Table, - TableBody, - TableCell, - TableHead, - TableRow, -} from "@common/components"; -import { SitePageLayout } from "./layout"; -import { IPManagementDialog } from "./ip-management-dialog"; - -function RegisterPage() { - return ( - -
-
-

사이트 등록/수정

-
-
- - - - - - - - 사이트 코드 - - - - - - 사이트 구분 - -
- - - - - - - 내부 - 외부 - - - - -
-
-
- - 사이트 디자인 - - - - - - - - 라이트 - 다크 - - - - - - - 사이트명 - - - - - - 사이트 설명 - - - - - - 사이트 하단 표기 주소 - - - - - - 사이트 하단 표기 전화번호 - - - - - - 사이트 하단 표기 팩스번호 - - - - - - 사이트 하단 표기 내용 - - - - - - 북마크 아이콘 - - - - - - 기본 사이트 여부 - - -
- - -
-
- - -
-
-
-
- - 사용 여부 - - -
- - -
-
- - -
-
-
-
-
-
-
-
-
- - - -
-
- ); -} - -export { RegisterPage }; diff --git a/apps/cs/src/pages/site/ui/site-form.tsx b/apps/cs/src/pages/site/ui/site-form.tsx new file mode 100644 index 000000000..c554a868c --- /dev/null +++ b/apps/cs/src/pages/site/ui/site-form.tsx @@ -0,0 +1,431 @@ +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { type UseFormReturn } from "react-hook-form"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { CodeSelect } from "@/entities/code"; +import { + FormItem, + FormControl, + FormField, + Form, + FormMessage, +} from "@/shared/ui/form"; +import { type ISiteDetail, type SiteFormData } from "../model/site-interface"; +import { IPManagementDialog } from "./ip-management-dialog"; +import { + DomainManagementDialog, + type Domain, +} from "./domain-management-dialog"; + +type SiteFormProps = { + form: UseFormReturn; + siteId?: string; + handleSave: (data: SiteFormData) => void; + handleDelete?: () => void; +}; + +function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { + const router = useRouter(); + const { message, confirm, onConfirm, onCancel } = useConfirm(); + + const [viewState, setViewState] = useState>({}); + const [representativeDomain, setRepresentativeDomain] = useState(""); + + const isModifyMode = !!siteId; + + const handleViewStateChange = (key: keyof ISiteDetail) => (value: string) => { + setViewState({ ...viewState, [key]: value }); + }; + + const handleDomainSave = (rows: Domain[]) => { + setRepresentativeDomain( + rows.find((row) => row.isRepresentative)?.domainUrl || "", + ); + }; + + const onSubmit = (data: SiteFormData) => { + handleSave(data); + }; + + const onDelete = async () => { + if (await confirm("정말 삭제하시겠습니까?")) { + handleDelete?.(); + } + }; + + return ( + <> +
+ +
+
+

+ 사이트 등록/수정 +

+
+
+ + + + + + + + 사이트 코드 + + ( + + + + + + + )} + /> + + + + 사이트 구분 + +
+ ( + + + { + handleViewStateChange("siteKndCd")(value); + field.onChange(value); + }} + /> + + + + )} + /> + {isModifyMode && ( + + )} +
+
+
+ + 사이트 디자인 + + ( + + + + + + + )} + /> + + + + 사이트명 + + ( + + + + + + + )} + /> + + + + 사이트 설명 + + ( + + + + + + + )} + /> + + + {isModifyMode && ( + + 사이트 도메인 + +
+
+ {representativeDomain} +
+ +
+
+
+ )} + + 사이트 하단 표기 주소 + + ( + + + + + + + )} + /> + + + + 사이트 하단 표기 전화번호 + + ( + + + + + + + )} + /> + + + + 사이트 하단 표기 팩스번호 + + ( + + + + + + + )} + /> + + + + 사이트 하단 표기 내용 + + ( + + + + + + + )} + /> + + + + 북마크 아이콘 + + ( + + + + + + + )} + /> + + + + 기본 사이트 여부 + + ( + + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> +
+
+ + 사용 여부 + + ( + + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> +
+
+
+
+
+
+
+ + + {isModifyMode && ( + + )} +
+
+ + + +
+

{message}

+
+ + + + + + + + +
+
+ + ); +} + +export { SiteForm }; diff --git a/apps/cs/src/pages/site/ui/site-page.tsx b/apps/cs/src/pages/site/ui/site-page.tsx index c13284ce8..1e026c6ec 100644 --- a/apps/cs/src/pages/site/ui/site-page.tsx +++ b/apps/cs/src/pages/site/ui/site-page.tsx @@ -7,14 +7,21 @@ import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { Button, Input, DataTable } from "@common/components"; -import { SiteApi } from "@/entities/site/api/site-service"; -import { ENDPOINT } from "@/shared/config/api"; -import { columns } from "../model/__mocks__"; +import { ENDPOINT } from "@/shared/config"; +import { SiteApi } from "../api/site-service"; +import { type ISite, type ISiteParams } from "../model/site-interface"; +import { columns } from "../model/table-columns"; import { SitePageLayout } from "./layout"; -import type { ISiteQuery } from "@/entities/site/model/site-interface"; function SitePage() { - const [siteQuery, setSiteQuery] = useState({ + const router = useRouter(); + const { data: siteInfo, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES], + queryFn: () => SiteApi.siteInfo(siteQuery), + enabled: false, + }); + + const [siteQuery, setSiteQuery] = useState({ siteNm: "", siteKndCd: "", useYn: "", @@ -23,10 +30,10 @@ function SitePage() { sort: [], }); - const { data: siteInfo, refetch } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.SITES], - queryFn: () => SiteApi.siteInfo(siteQuery), - enabled: false, + const table = useReactTable({ + data: siteInfo?.content ?? [], + columns, + getCoreRowModel: getCoreRowModel(), }); const handleSubmit = (e: React.FormEvent) => { @@ -34,15 +41,13 @@ function SitePage() { refetch(); }; - const router = useRouter(); - const table = useReactTable({ - data: siteInfo?.content ?? [], - columns, - getCoreRowModel: getCoreRowModel(), - }); + const handleRowClick = (row: ISite) => { + router.push(`/site/register/${row.siteId}`); + }; useEffect(() => { refetch(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( @@ -71,7 +76,7 @@ function SitePage() {
- +
diff --git a/apps/cs/src/shared/api/index.ts b/apps/cs/src/shared/api/index.ts new file mode 100644 index 000000000..b7b9d6121 --- /dev/null +++ b/apps/cs/src/shared/api/index.ts @@ -0,0 +1 @@ +export { client } from "./client"; diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 052eea56a..bab8cac7b 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -10,6 +10,7 @@ export const ENDPOINT = { }, CMS_SERVICE: { SITES: "/cms-service/api/v1/sites", + CODE: "/cms-service/api/v1/code/cache", }, }; diff --git a/apps/cs/src/shared/config/index.ts b/apps/cs/src/shared/config/index.ts new file mode 100644 index 000000000..cd83447d5 --- /dev/null +++ b/apps/cs/src/shared/config/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./env"; diff --git a/apps/cs/src/shared/lib/cookie.ts b/apps/cs/src/shared/lib/cookie.ts index 82db637bd..8f5d0011b 100644 --- a/apps/cs/src/shared/lib/cookie.ts +++ b/apps/cs/src/shared/lib/cookie.ts @@ -1,5 +1,3 @@ -"use client"; - import Cookies from "js-cookie"; export function getCookie(name: string): string | undefined { diff --git a/apps/cs/src/shared/lib/index.ts b/apps/cs/src/shared/lib/index.ts new file mode 100644 index 000000000..259525de6 --- /dev/null +++ b/apps/cs/src/shared/lib/index.ts @@ -0,0 +1,2 @@ +export { getCookie, setCookie, removeCookie } from "./cookie"; +export { useUserStore } from "./use-user-store"; diff --git a/apps/cs/src/shared/lib/use-user-store.ts b/apps/cs/src/shared/lib/use-user-store.ts new file mode 100644 index 000000000..f4bf9763a --- /dev/null +++ b/apps/cs/src/shared/lib/use-user-store.ts @@ -0,0 +1,14 @@ +import { useStore } from "zustand"; +import { useContext } from "react"; +import { type UserStore } from "@/shared/store/user-store"; +import { StoreContext } from "../providers/store-provider"; + +export const useUserStore = (selector: (store: UserStore) => T): T => { + const storeContext = useContext(StoreContext); + + if (!storeContext) { + throw new Error("useUserStore must be used within StoreProvider"); + } + + return useStore(storeContext.userStore, selector); +}; diff --git a/apps/cs/src/shared/provider/auth-provider.tsx b/apps/cs/src/shared/provider/auth-provider.tsx deleted file mode 100644 index 7c827e948..000000000 --- a/apps/cs/src/shared/provider/auth-provider.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { createContext, useContext, useState } from "react"; -import { client } from "../api/client"; -import { ENDPOINT } from "../config/api"; -import { AUTH_USER_ID } from "../config/env"; -import { getCookie } from "../lib/cookie"; -import { AxiosError } from "axios"; -import { LoginApi } from "@/entities/auth/login"; - -interface IUserInfo { - regDt: string; - mdfcnDt: string; - rgtr: string; - mdfr: string; - userUnqId: string; - userId: string; - userName: string; - email: string; - encryptedPassword: string; - refreshToken: string; - userStateCode: string; - lastLoginDate: string; - loginFailCount: number; - pswdHnt: string; - pswdAns: string; - mphn: string; - telno: string; - zip: string; - addr: string; - daddr: string; - laeSeCd: string; - ogdpSeCd: string; - aprvYn: string; - autzrId: string; - expryYmd: string; - lastPswdChgDt: string; - googleId: string; - kakaoId: string; - naverId: string; - isSocialUser: boolean; - hasPassword: boolean; -} - -export interface AuthContextType { - userInfo: IUserInfo | null; -} - -export const AuthContext = createContext(null); - -export const AuthProvider = ({ children }: { children: React.ReactNode }) => { - const [userInfo, setUserInfo] = useState(null); - - useQuery({ - queryKey: [ENDPOINT.USER_SERVICE.USERS], - queryFn: async () => { - try { - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/${getCookie(AUTH_USER_ID)}`, - ); - setUserInfo(response.data); - return response; - } catch (error) { - console.error(error); - if (error instanceof AxiosError) { - if (error.response?.status === 401) { - try { - await LoginApi.silentRefresh(); - } catch (refreshError) { - console.error("Token refresh failed:", refreshError); - setUserInfo(null); - } - } - } - } - }, - }); - - const authContextValue: AuthContextType = { - userInfo, - }; - - return ( - - {children} - - ); -}; - -export const useAuth = (): AuthContextType => { - const context = useContext(AuthContext); - if (!context) { - throw new Error("useAuth must be used within an AuthProvider"); - } - return context; -}; diff --git a/apps/cs/src/shared/provider/cookie-provider.tsx b/apps/cs/src/shared/provider/cookie-provider.tsx deleted file mode 100644 index c50050cf5..000000000 --- a/apps/cs/src/shared/provider/cookie-provider.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { createContext, useContext } from "react"; -import { getCookie, setCookie, removeCookie } from "../lib/cookie"; - -export interface CookieContextType { - getCookie: (name: string) => string | undefined; - setCookie: ( - name: string, - value: string, - options: Cookies.CookieAttributes, - ) => void; - removeCookie: (name: string, options: Cookies.CookieAttributes) => void; -} - -export const CookieContext = createContext(null); - -export function CookieProvider({ children }: { children: React.ReactNode }) { - const cookieContextValue: CookieContextType = { - getCookie, - setCookie, - removeCookie, - }; - - return ( - - {children} - - ); -} - -export function useCookie(): CookieContextType { - const context = useContext(CookieContext); - if (!context) { - throw new Error("useCookie must be used within a CookieProvider"); - } - return context; -} diff --git a/apps/cs/src/shared/provider/index.ts b/apps/cs/src/shared/provider/index.ts deleted file mode 100644 index 8d018ff08..000000000 --- a/apps/cs/src/shared/provider/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { AuthProvider, useAuth } from "./auth-provider"; -export { CookieProvider, useCookie } from "./cookie-provider"; -export { QueryProvider } from "./query-provider"; diff --git a/apps/cs/src/shared/providers/index.ts b/apps/cs/src/shared/providers/index.ts new file mode 100644 index 000000000..5d9f096a3 --- /dev/null +++ b/apps/cs/src/shared/providers/index.ts @@ -0,0 +1,2 @@ +export { QueryProvider } from "./query-provider"; +export { StoreProvider } from "./store-provider"; diff --git a/apps/cs/src/shared/provider/query-provider.tsx b/apps/cs/src/shared/providers/query-provider.tsx similarity index 95% rename from apps/cs/src/shared/provider/query-provider.tsx rename to apps/cs/src/shared/providers/query-provider.tsx index 65a8ca837..f16c42072 100644 --- a/apps/cs/src/shared/provider/query-provider.tsx +++ b/apps/cs/src/shared/providers/query-provider.tsx @@ -1,5 +1,3 @@ -"use client"; - import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient(); diff --git a/apps/cs/src/shared/providers/store-provider.tsx b/apps/cs/src/shared/providers/store-provider.tsx new file mode 100644 index 000000000..ec913f235 --- /dev/null +++ b/apps/cs/src/shared/providers/store-provider.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { createContext, useRef } from "react"; +import { createUserStore } from "@/shared/store/user-store"; + +type StoreContextType = { + userStore: ReturnType; +}; + +export const StoreContext = createContext( + undefined, +); + +export const StoreProvider = ({ children }: { children: React.ReactNode }) => { + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = { + userStore: createUserStore(), + }; + } + + return ( + + {children} + + ); +}; diff --git a/apps/cs/src/shared/store/index.ts b/apps/cs/src/shared/store/index.ts new file mode 100644 index 000000000..4dae19ced --- /dev/null +++ b/apps/cs/src/shared/store/index.ts @@ -0,0 +1 @@ +export { createUserStore, type IUserInfo } from "./user-store"; diff --git a/apps/cs/src/entities/user/model/user-interface.ts b/apps/cs/src/shared/store/user-store.ts similarity index 54% rename from apps/cs/src/entities/user/model/user-interface.ts rename to apps/cs/src/shared/store/user-store.ts index cba4a6206..00379fb1f 100644 --- a/apps/cs/src/entities/user/model/user-interface.ts +++ b/apps/cs/src/shared/store/user-store.ts @@ -1,6 +1,4 @@ -export interface IUser { - userUniqId: string; -} +import { createStore } from "zustand/vanilla"; export interface IUserInfo { regDt: string; @@ -36,6 +34,23 @@ export interface IUserInfo { hasPassword: boolean; } -export interface UserService { - userInfo: (payload: IUser) => Promise; -} +export type UserState = { + userInfo: IUserInfo | null; +}; + +export type UserActions = { + setUserInfo: (userInfo: IUserInfo | null) => void; +}; + +export type UserStore = UserState & UserActions; + +export const defaultInitState: UserState = { + userInfo: null, +}; + +export const createUserStore = (initState: UserState = defaultInitState) => { + return createStore()((set) => ({ + ...initState, + setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })), + })); +}; diff --git a/apps/cs/src/shared/ui/form.tsx b/apps/cs/src/shared/ui/form.tsx new file mode 100644 index 000000000..bd579d263 --- /dev/null +++ b/apps/cs/src/shared/ui/form.tsx @@ -0,0 +1,175 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { cn, Label } from "@common/components"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +