diff --git a/ssh/package-lock.json b/ssh/package-lock.json index 80c695c..ec14884 100644 --- a/ssh/package-lock.json +++ b/ssh/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "leaflet": "^1.9.4", "lucide-react": "^0.542.0", "react": "^19.1.1", @@ -3014,6 +3015,34 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/ssh/package.json b/ssh/package.json index a16fd35..50135d9 100644 --- a/ssh/package.json +++ b/ssh/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "leaflet": "^1.9.4", "lucide-react": "^0.542.0", "react": "^19.1.1", diff --git a/ssh/src/components/Layout/index.tsx b/ssh/src/components/Layout/index.tsx index c67e38b..007669c 100644 --- a/ssh/src/components/Layout/index.tsx +++ b/ssh/src/components/Layout/index.tsx @@ -6,7 +6,7 @@ export default function RootLayout() { return ( <> - + diff --git a/ssh/src/components/ui/carousel.tsx b/ssh/src/components/ui/carousel.tsx new file mode 100644 index 0000000..9c2b9bf --- /dev/null +++ b/ssh/src/components/ui/carousel.tsx @@ -0,0 +1,260 @@ +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + + + {children} + + + ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( + + + + ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( + + ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + + Previous slide + + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + + Next slide + + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/ssh/src/pages/Home/components/firstsection/hero.tsx b/ssh/src/pages/Home/components/firstsection/hero.tsx new file mode 100644 index 0000000..7ab6db2 --- /dev/null +++ b/ssh/src/pages/Home/components/firstsection/hero.tsx @@ -0,0 +1,106 @@ +import { Button } from "@/components/ui/button"; + +export default function Hero() { + return ( + + + + + + + 대화로 이어지는 당신의 금융 동반자 + 마음잇는 목소리 + + + + 우리 사이트 한줄 소개 + + + + + + 지금 바로 사용해보세요 + + + + + 시작하기 + + + + 더 알아보기 + + + + + + ); +} + +function Waveform({ className = "" }: { className?: string }) { + const base = [ + 60, 96, 78, 64, 72, 66, 56, 82, 60, 82, 56, 66, 72, 64, 78, 96, 60, + ]; + const scale = 1.4; + const mid = 60; + const vbH = 120; + + const scaled = base.map((h) => h * scale); + const maxH = Math.max(...scaled); + const safety = 0.95; + const fit = Math.min(1, (vbH * safety) / maxH); + const heights = scaled.map((h) => h * fit); + + return ( + + + + + + + + + {heights.map((h, i) => { + const barW = 16; + const gap = 14; + const x = 20 + i * (barW + gap); + const y = mid - h / 2; + const delay = (i % 6) * 0.08; + return ( + + ); + })} + + ); +} diff --git a/ssh/src/pages/Home/components/secondsection/servicecarousel.tsx b/ssh/src/pages/Home/components/secondsection/servicecarousel.tsx new file mode 100644 index 0000000..481cfa2 --- /dev/null +++ b/ssh/src/pages/Home/components/secondsection/servicecarousel.tsx @@ -0,0 +1,106 @@ +"use client"; + +import * as React from "react"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + type CarouselApi, +} from "@/components/ui/carousel"; +import { MessagesSquare, MapPinned, Heart, ClipboardCheck } from "lucide-react"; + +export default function ServiceCarousel() { + const [api, setApi] = React.useState(null); + const [activeIndex, setActiveIndex] = React.useState(0); + + React.useEffect(() => { + if (!api) return; + + const onSelect = () => setActiveIndex(api.selectedScrollSnap()); + + onSelect(); + api.on("select", onSelect); + api.on("reInit", onSelect); + + return () => { + api.off("select", onSelect); + api.off("reInit", onSelect); + }; + }, [api]); + + const services = [ + { + icon: , + title: "간편한 은행 안내 서비스", + desc: "말로 하시면 글자로 바꿔주고, 글자는 소리로 읽어주는 기능을 지원합니다. 음성으로도, 문자로도 쉽고 편하게 은행 서비스를 이용할 수 있습니다.", + }, + { + icon: , + title: "ATM 위치 안내", + desc: "늦은 밤이나 인터넷 뱅킹을 쓰기 어려울 때, 내 주변에서 가까운 ATM을 바로 찾아드립니다. 은행 이름과 이용 가능 시간도 함께 알려주어 편리하고 안전하게 현금을 찾을 수 있습니다.", + }, + { + icon: , + title: "사전 유산 서비스", + desc: "내가 세상을 떠난 후 남은 재산이 어디에 쓰일지 알 수 없을 때, 미리 원하는 곳을 지정해 의미 있게 사용되도록 할 수 있습니다.", + }, + { + icon: , + title: "보이스 피싱 진단", + desc: "내가 받은 연락이 혹시 보이스피싱일까? 의심될 때 직접 전화번호 또는 계좌번호를 조회해서 확인하고, 단계별 검사를 통해 자가 진단할 수 있습니다.", + }, + ]; + + return ( + + + + 우리 서비스를 소개하겠습니다 + + + "이제는 한줄 한줄 직접 대화하는 듯한 설명으로 금융 서비스에 다가가요" + + + + + {services.map((s, i) => ( + + + {s.icon} + {s.title} + {s.desc} + + + ))} + + + + + + + + + + + + + + + ); +} diff --git a/ssh/src/pages/Home/index.tsx b/ssh/src/pages/Home/index.tsx index a60423f..39ddf5d 100644 --- a/ssh/src/pages/Home/index.tsx +++ b/ssh/src/pages/Home/index.tsx @@ -1,5 +1,13 @@ +import Hero from "./components/firstsection/hero"; +import ServiceCarousel from "./components/secondsection/servicecarousel"; + const HomePage = () => { - return 홈페이지; + return ( + <> + + + > + ); }; export default HomePage;
+ 우리 사이트 한줄 소개 +
+ 우리 서비스를 소개하겠습니다 +
{s.desc}