diff --git a/package-lock.json b/package-lock.json index be1e63d..d0db919 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "next": "14.2.32", "react": "^18", "react-dom": "^18", + "react-responsive": "^10.0.1", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -5782,6 +5783,12 @@ } } }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", + "license": "BSD" + }, "node_modules/css-select": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", @@ -8006,6 +8013,12 @@ "dev": true, "license": "MIT" }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -9064,6 +9077,15 @@ "semver": "bin/semver.js" } }, + "node_modules/matchmediaquery": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz", + "integrity": "sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==", + "license": "MIT", + "dependencies": { + "css-mediaquery": "^0.1.2" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9478,7 +9500,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10562,7 +10583,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -10759,7 +10779,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-refresh": { @@ -10772,6 +10791,24 @@ "node": ">=0.10.0" } }, + "node_modules/react-responsive": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-10.0.1.tgz", + "integrity": "sha512-OM5/cRvbtUWEX8le8RCT8scA8y2OPtb0Q/IViEyCEM5FBN8lRrkUOZnu87I88A6njxDldvxG+rLBxWiA7/UM9g==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.0", + "matchmediaquery": "^0.4.2", + "prop-types": "^15.6.1", + "shallow-equal": "^3.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -11513,6 +11550,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shallow-equal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", + "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 8aa54ab..face2b9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "next": "14.2.32", "react": "^18", "react-dom": "^18", + "react-responsive": "^10.0.1", "tailwind-merge": "^3.3.1" }, "devDependencies": { diff --git a/src/api/employer.ts b/src/api/employer.ts new file mode 100644 index 0000000..954fdc9 --- /dev/null +++ b/src/api/employer.ts @@ -0,0 +1,50 @@ +import axios from '@/lib/axios'; +import RegisterFormData from '@/types/myShop'; +import { default as originAxios } from 'axios'; + +export async function postShop(body: RegisterFormData) { + const { address1, address2, category, description, name, originalHourlyPay, image } = body; + + const imageUrl = image + ? `https://bootcamp-project-api.s3.ap-northeast-2.amazonaws.com/${image.name}` + : ''; + + const tmpBody = { + address1, + address2, + category, + description, + name, + originalHourlyPay, + imageUrl, + }; + const { data } = await axios.post('/shops', tmpBody); +} + +export async function postPresignedUrl(imageName: string) { + const { data } = await axios.post('/images', { name: imageName }); + console.log(data); + return data.item.url; +} + +export async function uploadImage(presignedUrl: string, file: File) { + const result = await originAxios.put(presignedUrl, file); +} + +export async function getPresignedUrl(presignedUrl: string) { + // 1. URL 객체 생성 + const url = new URL(presignedUrl); + + // 2. 쿼리 파라미터를 제거 (URL 객체의 search 속성을 비움) + url.search = ''; + + // 3. 쿼리 파라미터가 제거된 새 URL 문자열을 얻습니다. + const baseUrl = url.toString(); + + const result = await originAxios.get(baseUrl); +} + +export async function getShop(shopId: string) { + const { data } = await axios.get(`/shops/${shopId}`); + return data; +} diff --git a/src/components/features/my-shop/registerAddress.tsx b/src/components/features/my-shop/registerAddress.tsx new file mode 100644 index 0000000..0bc7cbb --- /dev/null +++ b/src/components/features/my-shop/registerAddress.tsx @@ -0,0 +1,43 @@ +import { Dropdown, Input } from '@/components/ui'; +import { ADDRESS_CODE } from '@/constants/dropdown'; +import RegisterFormData from '@/types/myShop'; + +interface Props { + formData: RegisterFormData; + handleChange: (key: keyof RegisterFormData, value: string) => void; +} + +const RegisterAddress = ({ formData, handleChange }: Props) => { + return ( + <> +
+
+
+ 주소 + * +
+ handleChange('address1', val)} + className='w-full' + /> +
+
+ handleChange('address2', e.target.value)} + placeholder='입력' + /> +
+
+ + ); +}; + +export default RegisterAddress; diff --git a/src/components/features/my-shop/registerDescription.tsx b/src/components/features/my-shop/registerDescription.tsx new file mode 100644 index 0000000..4e820a0 --- /dev/null +++ b/src/components/features/my-shop/registerDescription.tsx @@ -0,0 +1,24 @@ +import RegisterFormData from '@/types/myShop'; + +interface Props { + formData: RegisterFormData; + handleChange: (key: keyof RegisterFormData, value: string) => void; +} + +const RegisterDescription = ({ formData, handleChange }: Props) => { + return ( + <> +
+ 가게 설명 + +
+ + ); +}; + +export default RegisterDescription; diff --git a/src/components/features/my-shop/registerImage.tsx b/src/components/features/my-shop/registerImage.tsx new file mode 100644 index 0000000..c9a2a47 --- /dev/null +++ b/src/components/features/my-shop/registerImage.tsx @@ -0,0 +1,39 @@ +import { Icon } from '@/components/ui'; +import Image from 'next/image'; +import { ChangeEvent } from 'react'; + +interface Props { + preview: string | null; + handleImageChange: (e: ChangeEvent) => void; +} + +const RegisterImage = ({ preview, handleImageChange }: Props) => { + return ( + <> +
+
+ 가게 이미지 + * +
+ +
+ + ); +}; + +export default RegisterImage; diff --git a/src/components/features/my-shop/registerModal.tsx b/src/components/features/my-shop/registerModal.tsx new file mode 100644 index 0000000..b1c5536 --- /dev/null +++ b/src/components/features/my-shop/registerModal.tsx @@ -0,0 +1,57 @@ +import { Modal } from '@/components/ui'; +import { useRouter } from 'next/router'; + +interface Props { + openWarning: boolean; + setOpenWarning: (value: boolean) => void; + openCancel: boolean; + setOpenCancel: (value: boolean) => void; + openConfirm: boolean; + setOepnConfirm: (value: boolean) => void; +} + +const RegisterModal = ({ + openWarning, + setOpenWarning, + openCancel, + setOpenCancel, + openConfirm, + setOepnConfirm, +}: Props) => { + const router = useRouter(); + return ( + <> + setOpenWarning(false)} + variant='warning' + title='필수 항목을 작성해주세요.' + primaryText='확인' + onPrimary={() => setOpenWarning(false)} + /> + setOpenCancel(false)} + variant='warning' + title='취소하시겠습니까?' + primaryText='아니요' + secondaryText='예' + onSecondary={() => { + setOpenCancel(false); + router.push('/my-shop'); + }} + onPrimary={() => setOpenCancel(false)} + /> + setOepnConfirm(false)} + variant='success' + title='등록이 완료되었습니다.' + primaryText='확인' + onPrimary={() => router.push('/my-shop')} + /> + + ); +}; + +export default RegisterModal; diff --git a/src/components/features/my-shop/registerName.tsx b/src/components/features/my-shop/registerName.tsx new file mode 100644 index 0000000..93e0d01 --- /dev/null +++ b/src/components/features/my-shop/registerName.tsx @@ -0,0 +1,43 @@ +import { Dropdown, Input } from '@/components/ui'; +import { CATEGORY_CODE } from '@/constants/dropdown'; +import RegisterFormData from '@/types/myShop'; + +interface Props { + formData: RegisterFormData; + handleChange: (key: keyof RegisterFormData, value: string) => void; +} + +const RegisterName = ({ formData, handleChange }: Props) => { + return ( + <> +
+
+ handleChange('name', e.target.value)} + placeholder='입력' + /> +
+
+
+ 분류 + * +
+ handleChange('category', val)} + className='w-full' + /> +
+
+ + ); +}; + +export default RegisterName; diff --git a/src/components/features/my-shop/registerWage.tsx b/src/components/features/my-shop/registerWage.tsx new file mode 100644 index 0000000..3e4ead3 --- /dev/null +++ b/src/components/features/my-shop/registerWage.tsx @@ -0,0 +1,30 @@ +import { Input } from '@/components/ui'; +import RegisterFormData from '@/types/myShop'; +import { ChangeEvent } from 'react'; + +interface Props { + formData: RegisterFormData; + handleWageChange: (e: ChangeEvent) => void; +} + +const RegisterWage = ({ formData, handleWageChange }: Props) => { + return ( + <> +
+
+ +
+
+
+ + ); +}; + +export default RegisterWage; diff --git a/src/components/ui/pagination/pagination.tsx b/src/components/ui/pagination/pagination.tsx index 6e7b4b5..9aba82c 100644 --- a/src/components/ui/pagination/pagination.tsx +++ b/src/components/ui/pagination/pagination.tsx @@ -1,8 +1,8 @@ import { Icon } from '@/components/ui'; import { cn } from '@/lib/utils/cn'; import { useEffect, useState } from 'react'; +import { useMediaQuery } from 'react-responsive'; -const PAGE_GROUP_SIZE = 7; const BUTTON_ALIGN = 'flex items-center justify-center shrink-0'; interface PaginationProps { total: number; // 전체 개수 (count) @@ -15,35 +15,54 @@ interface PaginationProps { /* { + const isDesktop = useMediaQuery({ minWidth: 1028 }); + const isTablet = useMediaQuery({ minWidth: 744, maxWidth: 1027 }); + const [pageGroupSize, setPageGroupSize] = useState(7); const [pageGroup, setPageGroup] = useState(0); const totalPages = total ? Math.ceil(total / limit) : 0; const currentPage = Math.floor(offset / limit) + 1; // offset → page 변환 useEffect(() => { - const newGroup = Math.floor((currentPage - 1) / PAGE_GROUP_SIZE); + if (isDesktop) setPageGroupSize(10); + else if (isTablet) setPageGroupSize(7); + else setPageGroupSize(5); + }, [isDesktop, isTablet]); + + useEffect(() => { + const newGroup = Math.floor((currentPage - 1) / pageGroupSize); setPageGroup(newGroup); - }, [currentPage]); + }, [currentPage, pageGroupSize]); if (totalPages < 1) return null; - const startPage = pageGroup * PAGE_GROUP_SIZE + 1; - const endPage = Math.min(startPage + PAGE_GROUP_SIZE - 1, totalPages); + const startPage = pageGroup * pageGroupSize + 1; + const endPage = Math.min(startPage + pageGroupSize - 1, totalPages); const pageNumbers = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i); const isPrevDisabled = pageGroup === 0; - const isNextDisabled = (pageGroup + 1) * PAGE_GROUP_SIZE >= totalPages; + const isNextDisabled = (pageGroup + 1) * pageGroupSize >= totalPages; /* 이전 그룹으로 이동 */ const handlePrevPage = () => { if (!isPrevDisabled) { + const prevGroup = pageGroup - 1; + const prevStartPage = prevGroup * pageGroupSize + 1; setPageGroup(prev => Math.max(prev - 1, 0)); + + const newOffset = (prevStartPage - 1) * limit; + onPageChange(newOffset); } }; /* 다음 그룹으로 이동 */ const handleNextPage = () => { if (!isNextDisabled) { - setPageGroup(prev => ((prev + 1) * PAGE_GROUP_SIZE < totalPages ? prev + 1 : prev)); + const nextGroup = pageGroup + 1; + const nextStartPage = nextGroup * pageGroupSize + 1; + setPageGroup(prev => ((prev + 1) * pageGroupSize < totalPages ? prev + 1 : prev)); + + const newOffset = (nextStartPage - 1) * limit; + onPageChange(newOffset); } }; diff --git a/src/context/authProvider.tsx b/src/context/authProvider.tsx index 3b7b06b..1e4e87b 100644 --- a/src/context/authProvider.tsx +++ b/src/context/authProvider.tsx @@ -16,7 +16,7 @@ type AuthContextValue = { login: (credentials: LoginRequest) => Promise; logout: () => void; signup: (data: UserRequest) => Promise; - getUser: () => Promise; + getUser: (userId: string) => Promise; updateUser: (patch: Partial) => Promise; }; @@ -52,6 +52,7 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { const storedUserId = getStorage(USER_ID_KEY); if (storedToken) setToken(storedToken); if (storedUserId) setUserId(storedUserId); + if (storedUserId) getUser(storedUserId); }, []); // 로그인: /token → 토큰/사용자 ID 저장 → /users/{id}로 내 정보 동기화 @@ -84,11 +85,14 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { }, []); // 내 정보 재조회 - const getUser = useCallback(async () => { - if (!userId) throw new Error('로그인이 필요합니다'); - const me = await apiGetUser(userId); - setUser(me); - }, [userId]); + const getUser = useCallback( + async (userId: string) => { + if (!userId) throw new Error('로그인이 필요합니다'); + const me = await apiGetUser(userId); + setUser(me); + }, + [userId] + ); // 내 정보 수정 const updateUser = useCallback( diff --git a/src/pages/my-shop/edit.tsx b/src/pages/my-shop/edit.tsx new file mode 100644 index 0000000..0772ebb --- /dev/null +++ b/src/pages/my-shop/edit.tsx @@ -0,0 +1,9 @@ +const Edit = () => { + return ( + <> +
수정 페이지
+ + ); +}; + +export default Edit; diff --git a/src/pages/my-shop/index.tsx b/src/pages/my-shop/index.tsx new file mode 100644 index 0000000..e477f9d --- /dev/null +++ b/src/pages/my-shop/index.tsx @@ -0,0 +1,64 @@ +import { getShop } from '@/api/employer'; +import { Frame } from '@/components/layout'; +import { Button, Notice } from '@/components/ui'; +import useAuth from '@/hooks/useAuth'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; + +const Myshop = () => { + const { isLogin, user, role } = useAuth(); + const [shopData, setShopData] = useState({}); + // console.log('user :', user); + + useEffect(() => { + const get = async () => { + if (user?.shop) { + const res = await getShop(user.shop.item.id); + // console.log('shop:', res); + const { description, ...rest } = res.item; + const formattedShopData = { ...rest, shopDescription: description }; + setShopData(formattedShopData); + } + }; + get(); + }, [user]); + + return ( + <> + {user?.shop ? ( + <> + +
+ + +
+
+ + + ) : ( + + )} + + ); +}; + +export default Myshop; diff --git a/src/pages/my-shop/register.tsx b/src/pages/my-shop/register.tsx new file mode 100644 index 0000000..3c7d751 --- /dev/null +++ b/src/pages/my-shop/register.tsx @@ -0,0 +1,114 @@ +import { postPresignedUrl, postShop, uploadImage } from '@/api/employer'; +import RegisterAddress from '@/components/features/my-shop/registerAddress'; +import RegisterDescription from '@/components/features/my-shop/registerDescription'; +import RegisterImage from '@/components/features/my-shop/registerImage'; +import RegisterModal from '@/components/features/my-shop/registerModal'; +import RegisterName from '@/components/features/my-shop/registerName'; +import RegisterWage from '@/components/features/my-shop/registerWage'; +import { Container, Header, Wrapper } from '@/components/layout'; +import { Button, Icon } from '@/components/ui'; +import { NextPageWithLayout } from '@/pages/_app'; +import RegisterFormData from '@/types/myShop'; +import { ChangeEvent, useState } from 'react'; + +const Register: NextPageWithLayout = () => { + const [formData, setFormData] = useState({ + name: '', + category: undefined, + address1: undefined, + address2: '', + originalHourlyPay: '', + description: '', + image: null, + }); + + const [preview, setPreview] = useState(null); + const [openWarning, setOpenWarning] = useState(false); + const [openCancel, setOpenCancel] = useState(false); + const [openConfirm, setOepnConfirm] = useState(false); + + const handleChange = (key: keyof RegisterFormData, value: string) => { + setFormData(prev => ({ ...prev, [key]: value })); + }; + + const handleWageChange = (e: ChangeEvent) => { + const raw = e.target.value.replace(/,/g, ''); + if (!/^\d*$/.test(raw)) return; + const formatted = raw ? Number(raw).toLocaleString() : ''; + setFormData(prev => ({ ...prev, originalHourlyPay: formatted })); + }; + + const handleImageChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setFormData(prev => ({ ...prev, image: file })); + setPreview(URL.createObjectURL(file)); + }; + + const validateForm = () => { + return ( + !formData.name || + !formData.category || + !formData.address1 || + !formData.address2 || + !formData.originalHourlyPay || + !formData.image + ); + }; + + const handleSubmit = async () => { + if (validateForm()) { + setOpenWarning(true); + return; + } + setOepnConfirm(true); + // console.log('모든 값 작성 완료', formData); + if (formData.image) { + const presignedUrl = await postPresignedUrl(formData.image.name); + await uploadImage(presignedUrl, formData.image); + + await postShop(formData); + } + }; + + return ( + <> +
+ +
+

가게 정보

+ +
+ + + + + + + +
+
+ + ); +}; + +Register.getLayout = page => ( + +
+
{page}
+ +); + +export default Register; diff --git a/src/pages/testAuth.tsx b/src/pages/testAuth.tsx index 56ecd28..c63f9e8 100644 --- a/src/pages/testAuth.tsx +++ b/src/pages/testAuth.tsx @@ -1,10 +1,11 @@ import useAuth from '@/hooks/useAuth'; import axios from '@/lib/axios'; +import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; export default function TestAuthPage() { const { isLogin, user, getUser, logout, login } = useAuth(); - + const router = useRouter(); // 하이드레이션 에러 방지: 마운트 이후에만 localStorage 값을 렌더 const [mounted, setMounted] = useState(false); const [lsToken, setLsToken] = useState(''); @@ -74,7 +75,7 @@ export default function TestAuthPage() { setLoading(true); setMsg(''); try { - await getUser(); + // await getUser(); setMsg('getUser() 호출 성공: user 상태 갱신'); } catch (e: unknown) { setMsg('getUser() 실패: ' + pickErrorMessage(e)); @@ -125,6 +126,7 @@ export default function TestAuthPage() { return (
+

Test Auth (임시 테스트 전용)

diff --git a/src/types/myShop.ts b/src/types/myShop.ts new file mode 100644 index 0000000..57e07bd --- /dev/null +++ b/src/types/myShop.ts @@ -0,0 +1,11 @@ +interface RegisterFormData { + name: string; + category?: string; + address1?: string; + address2: string; + originalHourlyPay: number | string; + description?: string; + image?: File | null; +} + +export default RegisterFormData;