diff --git a/package.json b/package.json index d53ab45..9ac5142 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,11 @@ "clsx": "^2.1.1", "daisyui": "^5.0.43", "framer-motion": "^12.19.2", + "jotai": "^2.12.5", "prettier": "^3.6.2", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-hook-form": "^7.59.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc13b00..e06d2c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: framer-motion: specifier: ^12.19.2 version: 12.19.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + jotai: + specifier: ^2.12.5 + version: 2.12.5(@types/react@19.1.8)(react@19.1.0) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -50,6 +53,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + react-hook-form: + specifier: ^7.59.0 + version: 7.59.0(react@19.1.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -1138,6 +1144,18 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jotai@2.12.5: + resolution: {integrity: sha512-G8m32HW3lSmcz/4mbqx0hgJIQ0ekndKWiYP7kWVKi0p6saLXdSoye+FZiOFyonnd7Q482LCzm8sMDl7Ar1NWDw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -1419,6 +1437,12 @@ packages: react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + react-hook-form@7.59.0: + resolution: {integrity: sha512-kmkek2/8grqarTJExFNjy+RXDIP8yM+QTl3QL6m6Q8b2bih4ltmiXxH7T9n+yXNK477xPh5yZT/6vD8sYGzJTA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -2503,6 +2527,11 @@ snapshots: jiti@2.4.2: {} + jotai@2.12.5(@types/react@19.1.8)(react@19.1.0): + optionalDependencies: + '@types/react': 19.1.8 + react: 19.1.0 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -2686,6 +2715,10 @@ snapshots: react-fast-compare@3.2.2: {} + react-hook-form@7.59.0(react@19.1.0): + dependencies: + react: 19.1.0 + react@19.1.0: {} resolve-from@4.0.0: {} diff --git a/src/app/global.css b/src/app/global.css index f67e06e..98652f5 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -13,7 +13,7 @@ --color-mxl: #ecf5fe; --color-s: #191919; - --color-sd: #333; + --color-sd: #919090; --color-sl: #d9d9d9; --color-sxl: #dadada; diff --git a/src/app/stackflow/Stack.tsx b/src/app/stackflow/Stack.tsx index 4e021a2..b8ee044 100644 --- a/src/app/stackflow/Stack.tsx +++ b/src/app/stackflow/Stack.tsx @@ -5,6 +5,8 @@ import { JoinScreen } from '@/screen/join/ui'; import { PhotoLoadingScreen } from '@/screen/photo-loading/ui'; import { PhotoResultScreen } from '@/screen/photo-result/ui'; import { PhotoUploadScreen } from '@/screen/photo-upload/ui'; +import { ReservationScreen } from '@/screen/reservation/ui'; +import { UserScreen } from '@/screen/user/ui'; import { fetchSessionData } from '@/shared/utils'; import { basicUIPlugin } from '@stackflow/plugin-basic-ui'; import { basicRendererPlugin } from '@stackflow/plugin-renderer-basic'; @@ -20,11 +22,14 @@ export const { Stack, useFlow } = stackflow({ CompleteScreen, PhotoLoadingScreen, PhotoResultScreen, + ReservationScreen, + UserScreen, }, plugins: [ basicRendererPlugin(), basicUIPlugin({ theme: 'cupertino', + backgroundColor: '#F9F9F9', }), ], initialActivity: () => { diff --git a/src/assets/icons/icon-close.svg b/src/assets/icons/icon-close.svg new file mode 100644 index 0000000..7f0b0b0 --- /dev/null +++ b/src/assets/icons/icon-close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/icon-customer.svg b/src/assets/icons/icon-customer.svg new file mode 100644 index 0000000..2091b15 --- /dev/null +++ b/src/assets/icons/icon-customer.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/icon-logout.svg b/src/assets/icons/icon-logout.svg new file mode 100644 index 0000000..ef2715e --- /dev/null +++ b/src/assets/icons/icon-logout.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/icon-search-colored.svg b/src/assets/icons/icon-search-colored.svg new file mode 100644 index 0000000..183ae17 --- /dev/null +++ b/src/assets/icons/icon-search-colored.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index bd52db4..ba03eed 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -15,8 +15,15 @@ import HelpIcon from './icon-help.svg'; import SearchIcon from './icon-search.svg'; import CheckIcon from './icon-checked.svg'; import UnCheckedIcon from './icon-unchecked.svg'; +import LogoutIcon from './icon-logout.svg'; +import CustomerIcon from './icon-customer.svg'; +import CloseIcon from './icon-close.svg'; +import SearchColoredIcon from './icon-search-colored.svg'; export { + CloseIcon, + SearchColoredIcon, + CustomerIcon, Logo, CameraIcon, TractorBlackIcon, @@ -34,4 +41,5 @@ export { SearchIcon, CheckIcon, UnCheckedIcon, + LogoutIcon, }; diff --git a/src/screen/form/ui/FormScreen.tsx b/src/screen/form/ui/FormScreen.tsx index 26c9dab..ddf17bd 100644 --- a/src/screen/form/ui/FormScreen.tsx +++ b/src/screen/form/ui/FormScreen.tsx @@ -1,17 +1,35 @@ import { NormalAppBar } from '@/shared/ui'; import { FormContainer } from '@/widgets/form/ui'; +import ToolPickModal from '@/widgets/form/ui/ToolPickModal'; import { AppScreen } from '@stackflow/plugin-basic-ui'; +import { useState } from 'react'; export default function PhotoUploadScreen() { + const [selectedTool, setSelectedTool] = useState(''); + const [selectedLocation, setSelectedLocation] = useState(''); + const [selectedPrice, setSelectedPrice] = useState(0); + return ( - - - + <> + + + + + ); } diff --git a/src/screen/reservation/ui/ReservationScreen.tsx b/src/screen/reservation/ui/ReservationScreen.tsx new file mode 100644 index 0000000..ad757fe --- /dev/null +++ b/src/screen/reservation/ui/ReservationScreen.tsx @@ -0,0 +1,18 @@ +import { AppScreen } from '@stackflow/plugin-basic-ui'; +import { CenteredAppBar, Dock } from '@/shared/ui'; +import { ReservationContainer } from '@/widgets/reservation/ui'; + +export default function ReservationScreen() { + return ( + <> + + + + + + ); +} diff --git a/src/screen/reservation/ui/index.ts b/src/screen/reservation/ui/index.ts new file mode 100644 index 0000000..148f80b --- /dev/null +++ b/src/screen/reservation/ui/index.ts @@ -0,0 +1 @@ +export { default as ReservationScreen } from './ReservationScreen'; diff --git a/src/screen/user/ui/UserScreen.tsx b/src/screen/user/ui/UserScreen.tsx new file mode 100644 index 0000000..37ddf6c --- /dev/null +++ b/src/screen/user/ui/UserScreen.tsx @@ -0,0 +1,50 @@ +import { AppScreen } from '@stackflow/plugin-basic-ui'; +import { CenteredAppBar, Dock } from '@/shared/ui'; +import { fetchSessionData, removeSessionData } from '@/shared/utils'; +import type { User } from '@/shared/types'; +import { CustomerIcon, LogoutIcon } from '@/assets/icons'; + +export default function UserScreen() { + const { name, jumin } = fetchSessionData('userInfo') as User; + return ( + <> + +
+
+ +
+

{name}

+

+ {jumin.slice(0, 6)}-{jumin[7]}****** +

+
+
+
+ + +
+
+
+ + + ); +} diff --git a/src/screen/user/ui/index.ts b/src/screen/user/ui/index.ts new file mode 100644 index 0000000..598423a --- /dev/null +++ b/src/screen/user/ui/index.ts @@ -0,0 +1 @@ +export { default as UserScreen } from './UserScreen'; diff --git a/src/shared/api/request.ts b/src/shared/api/request.ts index 3440d87..420ef65 100644 --- a/src/shared/api/request.ts +++ b/src/shared/api/request.ts @@ -1,4 +1,6 @@ export const REQUEST = { JOIN: '/api/ocr/idcard', PHOTO_UPLOAD: '/chat/image', + RESERVATION_SEARCH: '/api/reserve', + LOCATION: '/api/locations/', }; diff --git a/src/shared/atom/index.ts b/src/shared/atom/index.ts new file mode 100644 index 0000000..133aa74 --- /dev/null +++ b/src/shared/atom/index.ts @@ -0,0 +1 @@ +export * from './modal'; diff --git a/src/shared/atom/modal.ts b/src/shared/atom/modal.ts new file mode 100644 index 0000000..7182f2b --- /dev/null +++ b/src/shared/atom/modal.ts @@ -0,0 +1,26 @@ +import { atom } from 'jotai'; + +import type { ModalItem } from '@/shared/types'; +import { MODAL } from '@/shared/constants'; + +type ModalInfo = { + [key in ModalItem]: { isOpen: boolean }; +}; + +const modals = Object.fromEntries( + Object.keys(MODAL).map(key => [key, { isOpen: false }]), +) as ModalInfo; + +export const modalAtom = atom(modals); + +export const updateModal = atom( + null, + (get, set, update: { key: ModalItem; isOpen: boolean }) => { + const currentModal = get(modalAtom); + const updatedModal = { + ...currentModal, + [update.key]: { isOpen: update.isOpen }, + }; + set(modalAtom, updatedModal); + }, +); diff --git a/src/shared/constants/dock.tsx b/src/shared/constants/dock.tsx index 7b1c649..f6aed85 100644 --- a/src/shared/constants/dock.tsx +++ b/src/shared/constants/dock.tsx @@ -3,28 +3,28 @@ import { PATH } from './path'; import { HomeIcon, HomeSelectedIcon, - // ReservationIcon, - // ReservationSelectedIcon, - // UserIcon, - // UserSelectedIcon, + ReservationIcon, + ReservationSelectedIcon, + UserIcon, + UserSelectedIcon, } from '@/assets/icons'; export const DOCK = { - // ['']: { - // title: '예약 현황', - // icon: , - // selectedIcon: , - // }, + [PATH.RESERVATION]: { + title: '예약 현황', + icon: , + selectedIcon: , + }, [PATH.HOME]: { title: '홈', icon: , selectedIcon: , }, - // ['.']: { - // title: '나의 정보', - // icon: , - // selectedIcon: , - // }, + [PATH.USER]: { + title: '나의 정보', + icon: , + selectedIcon: , + }, }; export const DOCK_ITEMS = Object.keys(DOCK) as Array; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index a9ba2d6..0c93df6 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -1,2 +1,3 @@ export * from './path'; export * from './dock'; +export * from './modal'; diff --git a/src/shared/constants/modal.ts b/src/shared/constants/modal.ts new file mode 100644 index 0000000..7e4752d --- /dev/null +++ b/src/shared/constants/modal.ts @@ -0,0 +1,3 @@ +export const MODAL = { + TOOL_PICK: 'toolPickModal', +} as const; diff --git a/src/shared/constants/path.ts b/src/shared/constants/path.ts index bae17f3..8797336 100644 --- a/src/shared/constants/path.ts +++ b/src/shared/constants/path.ts @@ -6,4 +6,6 @@ export const PATH = { PHOTO_LOADING: 'PhotoLoadingScreen', PHOTO_RESULT: 'PhotoResultScreen', FORM: 'FormScreen', + RESERVATION: 'ReservationScreen', + USER: 'UserScreen', } as const; diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index e9dd277..79645e1 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1 +1,2 @@ export { default as useImageUpload } from './useImageUpload'; +export { default as useModal } from './useModal'; diff --git a/src/shared/hooks/useModal.ts b/src/shared/hooks/useModal.ts new file mode 100644 index 0000000..0ac8117 --- /dev/null +++ b/src/shared/hooks/useModal.ts @@ -0,0 +1,25 @@ +import { useAtomValue, useSetAtom } from 'jotai'; + +import { modalAtom, updateModal } from '../atom'; +import type { ModalItem } from '../types'; + +export default function useModal() { + const modal = useAtomValue(modalAtom); + const setModal = useSetAtom(updateModal); + + const modalState = (key: ModalItem) => modal[key] || { isOpen: false }; + + const openModal = (key: ModalItem) => { + setModal({ key, isOpen: true }); + }; + + const closeModal = (key: ModalItem) => { + setModal({ key, isOpen: false }); + }; + + return { + modalState, + openModal, + closeModal, + }; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index b7a5106..0d82db9 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -3,3 +3,4 @@ export * from './path'; export * from './reservation'; export * from './user'; export * from './tools'; +export * from './modal'; diff --git a/src/shared/types/modal.ts b/src/shared/types/modal.ts new file mode 100644 index 0000000..e7c11a7 --- /dev/null +++ b/src/shared/types/modal.ts @@ -0,0 +1,3 @@ +import { MODAL } from '@/shared/constants'; + +export type ModalItem = (typeof MODAL)[keyof typeof MODAL]; diff --git a/src/shared/types/reservation.ts b/src/shared/types/reservation.ts index 399091a..4e57534 100644 --- a/src/shared/types/reservation.ts +++ b/src/shared/types/reservation.ts @@ -1,7 +1,10 @@ export type Reservation = { + id: number; tool: string; startDate: string; endDate: string; location: string; userName: string; + description: string; + image: string; }; diff --git a/src/shared/ui/AppBar.tsx b/src/shared/ui/AppBar.tsx index 27c8510..05a3ea5 100644 --- a/src/shared/ui/AppBar.tsx +++ b/src/shared/ui/AppBar.tsx @@ -37,3 +37,9 @@ export const NormalAppBar = (title: string, bgImage?: string) => ({ }, ...baseStyle, }); + +export const CenteredAppBar = (title: string) => ({ + ...baseStyle, + title: title, + backgroundColor: '#FFF', +}); diff --git a/src/shared/ui/Dock.tsx b/src/shared/ui/Dock.tsx index 581f2d2..61c3784 100644 --- a/src/shared/ui/Dock.tsx +++ b/src/shared/ui/Dock.tsx @@ -23,7 +23,10 @@ export default function Dock(isLoading: DockProps) { .map(i => i.name) .pop() as PathItem; - const render = current === PATH.HOME; + const render = + current === PATH.HOME || + current === PATH.RESERVATION || + current === PATH.USER; return ( <> diff --git a/src/shared/ui/Modal.tsx b/src/shared/ui/Modal.tsx new file mode 100644 index 0000000..6f94c27 --- /dev/null +++ b/src/shared/ui/Modal.tsx @@ -0,0 +1,40 @@ +import { type ReactNode } from 'react'; + +import { cn } from '../utils'; +import type { ModalItem } from '../types'; + +interface ModalProps { + children: ReactNode; + modalKey: ModalItem; + coloredBg?: boolean; + className?: string; +} + +export default function Modal({ + children, + modalKey, + coloredBg = false, + className, +}: ModalProps) { + return ( + <> +
+
+ {children} +
+
+ + ); +} diff --git a/src/shared/ui/ToolButton.tsx b/src/shared/ui/ToolButton.tsx index 6bef11e..f7f63bb 100644 --- a/src/shared/ui/ToolButton.tsx +++ b/src/shared/ui/ToolButton.tsx @@ -7,6 +7,7 @@ interface ToolButtonProps extends ButtonHTMLAttributes { toolType: string; description: string; selected: boolean; + quantity?: number; } export default function ToolButton({ @@ -14,24 +15,25 @@ export default function ToolButton({ toolType, description, selected, + quantity, ...rest }: ToolButtonProps) { return ( - +
+ + 임대요금 + + {selectedPrice}원 +
+
+
- + ); } const FormItem = ({ label, type, + onClick, + value, + onChange, }: { label: string; type: string; onClick?: () => void; + value?: string; + onChange?: (e: React.ChangeEvent) => void; }) => (
{label} - {type === 'search' ? : } + {type === 'search' ? ( + + ) : ( + + )}
); diff --git a/src/widgets/form/ui/FormInput.tsx b/src/widgets/form/ui/FormInput.tsx index b27391b..2ff9325 100644 --- a/src/widgets/form/ui/FormInput.tsx +++ b/src/widgets/form/ui/FormInput.tsx @@ -15,9 +15,11 @@ export default function FormInput({ if (isSearch) { return ( + )); + } + return <>; + }; + + const renderTools = () => { + if (tools) { + const filteredTools = search.trim() + ? tools.filter( + ({ tool, description }) => + tool.toLowerCase().includes(search.toLowerCase()) || + description.toLowerCase().includes(search.toLowerCase()), + ) + : tools; + + if (filteredTools.length === 0) { + return ( +
+

검색 결과가 없습니다.

+
+ ); + } + + return filteredTools.map( + ({ tool, quantity, description, image, price }) => ( + { + if (selectedTool === tool) { + setSelectedTool(''); + setSelectedPrice(0); + } else { + setSelectedTool(tool); + setSelectedPrice(price); + } + }} + /> + ), + ); + } + }; + + return ( + <> + {isOpen && ( + +

농기계 검색 창

+ +
+ setSearch(e.target.value)} + className="focus:border-m w-[244px] rounded-md border-[1px] border-[#e1e1e1] bg-white px-[15px] py-[12px] focus:outline-none" + /> + +
+
+ {renderButton()} +
+
+ {renderTools()} +
+ +
+ )} + + ); +} diff --git a/src/widgets/form/ui/index.ts b/src/widgets/form/ui/index.ts index b7a14c5..534a85b 100644 --- a/src/widgets/form/ui/index.ts +++ b/src/widgets/form/ui/index.ts @@ -1 +1,2 @@ export { default as FormContainer } from './FormContainer'; +export { default as ToolPickModal } from './ToolPickModal'; diff --git a/src/widgets/reservation/api/index.ts b/src/widgets/reservation/api/index.ts new file mode 100644 index 0000000..382270c --- /dev/null +++ b/src/widgets/reservation/api/index.ts @@ -0,0 +1 @@ +export * from './reservation'; diff --git a/src/widgets/reservation/api/reservation.ts b/src/widgets/reservation/api/reservation.ts new file mode 100644 index 0000000..e89f9b5 --- /dev/null +++ b/src/widgets/reservation/api/reservation.ts @@ -0,0 +1,14 @@ +import { get, REQUEST } from '@/shared/api'; +import type { Reservation } from '@/shared/types'; +import { useQuery } from '@tanstack/react-query'; + +const fetchReservation = async () => { + const response = await get({ + request: REQUEST.RESERVATION_SEARCH, + }); + return response.data; +}; + +export const useFetchReservation = () => { + return useQuery({ queryKey: ['reservation'], queryFn: fetchReservation }); +}; diff --git a/src/widgets/reservation/ui/ReservationContainer.tsx b/src/widgets/reservation/ui/ReservationContainer.tsx new file mode 100644 index 0000000..eb2c75c --- /dev/null +++ b/src/widgets/reservation/ui/ReservationContainer.tsx @@ -0,0 +1,43 @@ +import { useFetchReservation } from '../api'; +import ReservationItem from './ReservationItem'; + +export default function ReservationContainer() { + const { data, isError } = useFetchReservation(); + + const renderReservation = () => { + if (isError) + return ( +
+

데이터를 불러오던 중 오류가 발생했어요.

+
+ ); + if (!data) + return ( +
+

데이터를 가져오는 중...

+
+ ); + + if (data.length === 0) { + return ( +
+

예약 내역이 없습니다.

+
+ ); + } + + return ( +
+ {data.map(reservation => ( + + ))} +
+ ); + }; + + return ( +
+ {renderReservation()} +
+ ); +} diff --git a/src/widgets/reservation/ui/ReservationItem.tsx b/src/widgets/reservation/ui/ReservationItem.tsx new file mode 100644 index 0000000..ace2fb5 --- /dev/null +++ b/src/widgets/reservation/ui/ReservationItem.tsx @@ -0,0 +1,61 @@ +import type { Reservation } from '@/shared/types'; + +interface InfoRowProps { + label: string; + value: string; +} + +const InfoRow = ({ label, value }: InfoRowProps) => ( +
+

{label}

+

{value}

+
+); + +export default function ReservationItem({ + id, + image, + tool, + description, + startDate, + endDate, + location, +}: Reservation) { + const infoItems = [ + { label: '신청 일자', value: startDate }, + { label: '반납 일자', value: endDate }, + { label: '대여소', value: location }, + { label: '예약 현황', value: status || '진행중' }, + ]; + + return ( +
+
+ +
+

{tool}

+

+ {description || ''} +

+
+
+
+
+ {infoItems.map(({ label, value }) => ( + + ))} +
+ +
+ ); +} diff --git a/src/widgets/reservation/ui/index.ts b/src/widgets/reservation/ui/index.ts new file mode 100644 index 0000000..e375fa1 --- /dev/null +++ b/src/widgets/reservation/ui/index.ts @@ -0,0 +1,2 @@ +export { default as ReservationContainer } from './ReservationContainer'; +export { default as ReservationItem } from './ReservationItem';