diff --git a/src/app/home/index.tsx b/src/app/home/index.tsx index 9c7aa4f..7757298 100644 --- a/src/app/home/index.tsx +++ b/src/app/home/index.tsx @@ -27,7 +27,7 @@ import { useModal } from "@/hooks/useModal.ts"; import { useAtom } from "jotai"; import { loginState } from "@/store/user"; import { SubscriptionModalContent } from "@/components/common/modal/SubscriptionModalContent"; - +import FloatingWidget from "@/components/app/home/floating/floatingWidget"; type CoinInfoType = { [coin in "BTC" | "ETH" | "XRP"]: { @@ -112,14 +112,14 @@ export default function HomePage() { } }; - /** + /** * @description 구독버튼 클릭시 flowbit서비스에 대해 주기적으로 구독할 수 있는 함수입니다. */ - const openSubscriptionModal = () => { + const openSubscriptionModal = () => { open({ title: "구독 설정", content: , - isVisibleBtn: false + isVisibleBtn: false, }); }; @@ -226,7 +226,8 @@ export default function HomePage() { + ); +} diff --git a/src/components/app/home/floating/FloatingPanel.tsx b/src/components/app/home/floating/FloatingPanel.tsx new file mode 100644 index 0000000..f80b2c3 --- /dev/null +++ b/src/components/app/home/floating/FloatingPanel.tsx @@ -0,0 +1,71 @@ +import IconAIChartAnalyzeInactive from "@/assets/IconAIChartAnalyzeInactive.svg?react"; +import IconAIChartAnalyzeActive from "@/assets/IconAIChartAnalyzeActive.svg?react"; +import IconAIChartRecommendInactive from "@/assets/IconAIChartRecommendInactive.svg?react"; +import IconAIChartRecommendActive from "@/assets/IconAIChartRecommendActive.svg?react"; +import IconFunctionHelpInactive from "@/assets/IconFunctionHelpInactive.svg?react"; +import IconFunctionHelpActive from "@/assets/IconFunctionHelpActive.svg?react"; +import FloatingPanelLayout from "./FloatingPanelLayout"; +import { useTab } from "@/hooks/useTab"; +import AIChartAnalyze from "./AIChartAnalyze"; +import AIChartRecommendation from "./AIChartRecommendation"; +import FunctionHelp from "./FunctionHelp"; + +export default function FloatingPanel() { + const TabKey = { + CHART_ANALYZE: "CHART_ANALYZE", + CHART_RECOMMENDATION: "CHART_RECOMMENDATION", + FUNCTION_HELP: "FUNCTION_HELP", + } as const; + + const { Tabs } = useTab(TabKey.CHART_ANALYZE); + + const tabItems = [ + { + key: TabKey.CHART_ANALYZE, + label: "AI 차트해석", + activeIcon: , + inactiveIcon: , + content: , + }, + { + key: TabKey.CHART_RECOMMENDATION, + label: "AI 추천판단", + activeIcon: , + inactiveIcon: , + content: , + }, + { + key: TabKey.FUNCTION_HELP, + label: "기능 도움말", + activeIcon: , + inactiveIcon: , + content: , + }, + ]; + + return ( + + + + {tabItems.map(({ key, label, activeIcon, inactiveIcon }) => ( + + {label} + + ))} + + + {tabItems.map(({ key, content }) => ( + + {content} + + ))} + + + + ); +} diff --git a/src/components/app/home/floating/FloatingPanelLayout.tsx b/src/components/app/home/floating/FloatingPanelLayout.tsx new file mode 100644 index 0000000..865e872 --- /dev/null +++ b/src/components/app/home/floating/FloatingPanelLayout.tsx @@ -0,0 +1,41 @@ +import { PropsWithChildren } from "react"; +import { css } from "@emotion/react"; + +export default function FloatingPanelLayout({ children }: PropsWithChildren) { + return ( +
+
+ {children} +
+
+
+ ); +} + +const containerStyle = css` + position: absolute; + bottom: 9rem; + right: 1rem; +`; + +const panelStyle = css` + width: 390px; + height: calc(100dvh - 450px); + background-color: white; + padding: 1.5rem; + border-radius: 24px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); + border: 1px solid #e5e7eb; + position: relative; +`; + +const arrowStyle = css` + position: absolute; + right: 2rem; + bottom: -0.5rem; + width: 2rem; + height: 2rem; + background-color: white; + transform: rotate(45deg); + border-bottom-right-radius: 2px; +`; diff --git a/src/components/app/home/floating/FloatingWidgetLayout.tsx b/src/components/app/home/floating/FloatingWidgetLayout.tsx new file mode 100644 index 0000000..2257a51 --- /dev/null +++ b/src/components/app/home/floating/FloatingWidgetLayout.tsx @@ -0,0 +1,40 @@ +import { PropsWithChildren, forwardRef } from "react"; +import { css } from "@emotion/react"; + +interface FloatingWidgetLayoutProps extends PropsWithChildren { + containerRef: React.RefObject; +} + +const FloatingWidgetLayout = forwardRef< + HTMLDivElement, + FloatingWidgetLayoutProps +>(({ children, containerRef }) => { + return ( +
+
+ {children} +
+
+ ); +}); + +const wrapperStyle = css` + position: fixed; + bottom: 5rem; + right: 8rem; + button { + background: transparent; + border: none; + padding: 0; + margin: 0; + outline: none; + } +`; + +const containerStyle = css` + position: relative; +`; + +FloatingWidgetLayout.displayName = "FloatingWidgetLayout"; + +export default FloatingWidgetLayout; diff --git a/src/components/app/home/floating/FunctionHelp.tsx b/src/components/app/home/floating/FunctionHelp.tsx new file mode 100644 index 0000000..7f15828 --- /dev/null +++ b/src/components/app/home/floating/FunctionHelp.tsx @@ -0,0 +1,3 @@ +export default function FunctionHelp() { + return
FunctionHelp
; +} diff --git a/src/components/app/home/floating/floatingWidget.tsx b/src/components/app/home/floating/floatingWidget.tsx new file mode 100644 index 0000000..6e029c8 --- /dev/null +++ b/src/components/app/home/floating/floatingWidget.tsx @@ -0,0 +1,28 @@ +import { useRef } from "react"; +import useToggle from "@/hooks/useToggle"; +import useClickOutside from "@/hooks/useClickOutside"; +import FloatingWidgetLayout from "./FloatingWidgetLayout"; +import FloatingPanel from "./FloatingPanel"; +import FloatingButton from "./FloatingButton"; + +export default function FloatingWidget() { + const floatingRef = useRef(null); + const { + value: isOpen, + toggleValue: toggleOpen, + setValue: setIsOpen, + } = useToggle(false); + + useClickOutside(floatingRef, () => { + if (isOpen) { + setIsOpen(false); + } + }); + + return ( + + {isOpen && } + + + ); +} diff --git a/src/hooks/useClickOutside.tsx b/src/hooks/useClickOutside.tsx new file mode 100644 index 0000000..3ca1828 --- /dev/null +++ b/src/hooks/useClickOutside.tsx @@ -0,0 +1,23 @@ +import { RefObject, useEffect } from "react"; + +export default function useClickOutside( + ref: RefObject, + handler: () => void, +) { + useEffect(() => { + const listener = (event: MouseEvent | TouchEvent) => { + if (!ref.current || ref.current.contains(event.target as Node)) { + return; + } + handler(); + }; + + document.addEventListener("mousedown", listener); + document.addEventListener("touchstart", listener); + + return () => { + document.removeEventListener("mousedown", listener); + document.removeEventListener("touchstart", listener); + }; + }, [ref, handler]); +} diff --git a/src/hooks/useTab.tsx b/src/hooks/useTab.tsx new file mode 100644 index 0000000..883c50a --- /dev/null +++ b/src/hooks/useTab.tsx @@ -0,0 +1,123 @@ +import { ReactElement, ReactNode, useState } from "react"; +import { css } from "@emotion/react"; + +interface TabsProps { + children: ReactNode; +} + +interface TabsListProps { + children: ReactNode; +} + +interface TabsTriggerProps { + value: T; + children: ReactNode; + activeIcon?: ReactElement; + inactiveIcon?: ReactElement; +} + +interface TabsContentWrapperProps { + children: ReactNode; +} + +interface TabsContentProps { + value: T; + children: ReactNode; +} + +export function useTab(initialTab: T) { + const [activeTab, setActiveTab] = useState(initialTab); + + function Tabs({ children }: TabsProps) { + return ( +
+ {children} +
+ ); + } + + function List({ children }: TabsListProps) { + return ( +
+ {children} +
+ ); + } + + function Trigger({ + value, + children, + activeIcon, + inactiveIcon, + }: TabsTriggerProps) { + const isActive = activeTab === value; + + return ( + + ); + } + + function ContentWrapper({ children }: TabsContentWrapperProps) { + return ( +
+ {children} +
+ ); + } + + function Content({ value, children }: TabsContentProps) { + if (activeTab !== value) return null; + return <>{children}; + } + + Tabs.List = List; + Tabs.Trigger = Trigger; + Tabs.Content = Content; + Tabs.ContentWrapper = ContentWrapper; + + return { + Tabs, + }; +} diff --git a/src/hooks/useToggle.ts b/src/hooks/useToggle.ts new file mode 100644 index 0000000..15c84b4 --- /dev/null +++ b/src/hooks/useToggle.ts @@ -0,0 +1,13 @@ +import { useState } from "react"; + +export default function useToggle(initialValue: boolean) { + const [value, setValue] = useState(initialValue); + + const toggleValue = () => setValue((prev) => !prev); + + return { + value, + toggleValue, + setValue, + }; +}