diff --git "a/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217--refactor--\354\275\224\353\223\234-\353\246\254\355\214\251\355\206\240\353\247\201.md" "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217--refactor--\354\275\224\353\223\234-\353\246\254\355\214\251\355\206\240\353\247\201.md" new file mode 100644 index 0000000..d3fda40 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217--refactor--\354\275\224\353\223\234-\353\246\254\355\214\251\355\206\240\353\247\201.md" @@ -0,0 +1,17 @@ +--- +name: "♻️ [refactor] 코드 리팩토링" +about: 리팩토링을 위한 템플릿 +title: "♻️ [refactor] " +labels: "♻️ refactor" +assignees: '' + +--- + +## 📝 개요 +- 자세한 개요 작성 + +## ✔️ To-Do +- [ ] 투두 내용 작성 + +## 👀 ETC +- 참고자료 등 기타 내용 작성 diff --git "a/.github/ISSUE_TEMPLATE/\342\234\250--feat--\352\270\260\353\212\245-\354\266\224\352\260\200.md" "b/.github/ISSUE_TEMPLATE/\342\234\250--feat--\352\270\260\353\212\245-\354\266\224\352\260\200.md" new file mode 100644 index 0000000..4f9e935 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\342\234\250--feat--\352\270\260\353\212\245-\354\266\224\352\260\200.md" @@ -0,0 +1,17 @@ +--- +name: "✨ [feat] 기능 추가" +about: 새로운 기능 추가 템플릿 +title: "✨ [feat] " +labels: "✨ feature" +assignees: '' + +--- + +## 📝 개요 +- 자세한 개요 작성 + +## ✔️ To-Do +- [ ] 투두 내용 작성 + +## 👀 ETC +- 참고자료 등 기타 내용 작성 diff --git "a/.github/ISSUE_TEMPLATE/\360\237\220\233--fix--\353\262\204\352\267\270-\354\210\230\354\240\225.md" "b/.github/ISSUE_TEMPLATE/\360\237\220\233--fix--\353\262\204\352\267\270-\354\210\230\354\240\225.md" new file mode 100644 index 0000000..c6d3346 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\220\233--fix--\353\262\204\352\267\270-\354\210\230\354\240\225.md" @@ -0,0 +1,17 @@ +--- +name: "\U0001F41B [fix] 버그 수정" +about: 버그 수정을 위한 템플릿 +title: "\U0001F41B [fix] " +labels: "\U0001F41B fix" +assignees: '' + +--- + +## 📝 개요 +- 자세한 개요 작성 + +## ✔️ To-Do +- [ ] 투두 내용 작성 + +## 👀 ETC +- 참고자료 등 기타 내용 작성 diff --git "a/.github/ISSUE_TEMPLATE/\360\237\232\200--chore--\352\270\260\355\203\200-\353\263\200\352\262\275\354\202\254\355\225\255.md" "b/.github/ISSUE_TEMPLATE/\360\237\232\200--chore--\352\270\260\355\203\200-\353\263\200\352\262\275\354\202\254\355\225\255.md" new file mode 100644 index 0000000..cdf8e54 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\232\200--chore--\352\270\260\355\203\200-\353\263\200\352\262\275\354\202\254\355\225\255.md" @@ -0,0 +1,17 @@ +--- +name: "\U0001F680 [chore] 기타 변경사항" +about: 기타 변경사항을 위한 템플릿 +title: "\U0001F680 [chore] " +labels: "\U0001F680 chore" +assignees: '' + +--- + +## 📝 개요 +- 자세한 개요 작성 + +## ✔️ To-Do +- [ ] 투두 내용 작성 + +## 👀 ETC +- 참고자료 등 기타 내용 작성 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..387fcf5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +## 📍 PR 타입 (하나 이상 선택) +- [ ] 기능 추가 +- [ ] 버그 수정 +- [ ] 의존성, 환경 변수, 빌드 관련 코드 업데이트 +- [ ] 기타 사소한 수정 + +## ❗️ 관련 이슈 링크 +Close # + +## 📌 개요 +- + +## 🔁 변경 사항 + +## 📸 스크린샷 (선택) + +## 👀 기타 더 이야기해볼 점 (선택) + +## 💬 리뷰 요구사항 (선택) + +## ✅ 체크 리스트 +- [ ] PR 템플릿에 맞추어 작성했어요. +- [ ] 변경 내용에 대한 테스트를 진행했어요. +- [ ] 프로그램이 정상적으로 동작해요. +- [ ] PR에 적절한 라벨을 선택했어요. +- [ ] 불필요한 코드는 삭제했어요. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..620511d --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,35 @@ +name: Build and Deploy on Self-Hosted Runner + +on: + push: + branches: + - develop + +jobs: + deploy: + runs-on: self-hosted + + steps: + - name: 코드 가져오기 (pull) + run: | + cd /home/tokkit/Tokkit-Client + git pull origin develop + + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Docker Compose Down + run: | + cd /home/tokkit + docker compose down + + - name: Docker Compose Build + run: | + cd /home/tokkit + docker compose build + + - name: Docker Compose Up + run: | + cd /home/tokkit + docker compose up -d + diff --git a/.gitignore b/.gitignore index 5ef6a52..1a8392b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,7 @@ yarn-error.log* # vercel .vercel - +.idea # typescript *.tsbuildinfo next-env.d.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd28328 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ + +FROM node:20 AS builder +WORKDIR /app +COPY . . +RUN npm install +RUN npm run build + +FROM node:20 +WORKDIR /app +COPY --from=builder /app . +EXPOSE 3000 +CMD ["npm", "run", "start"] + diff --git a/README.md b/README.md index e215bc4..d0f35eb 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,75 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +

+ Tokkit Logo +

+

“토큰이 있으면, 토킷이 있다.”

-## Getting Started +# 🐰 Tokkit-Client -First, run the development server: +_“토큰이 있으면, 토킷이 있다.”_ +**Tokkit**은 우리은행 예금 토큰 기반의 전자지갑 서비스입니다. +이 저장소는 **프론트엔드(Next.js)** 클라이언트 코드로, 사용자 인터페이스와 이벤트형 UX 구현을 담당합니다. + +--- + + +## ✨ 주요 기능 (프론트 주석 기준) + +- `/wallet` : 예금 토큰 잔액 확인 및 입출금 기능 +- `/store` : 바우처 카테고리별 탐색, 구매, 사용 +- `/mission` : 미션 달성 시 보상 시스템 +- `/admin` : 관리자용 바우처 및 사용자 관리 +- `/event` : 출석, 룰렛, 친구 초대 등 인터랙티브 이벤트 + +--- -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## 🌿 브랜치 규칙 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +- `main` : 배포 브랜치 +- `dev` : 개발 통합 브랜치 +- `feat/#{ISSUE_NUMBER}-작업 내용 (한글)` : 기능 개발 브랜치 +- `fix/#{ISSUE_NUMBER}-작업 내용 (한글)` : 버그 수정 브랜치 +- `hotfix/#{ISSUE_NUMBER}-작업 내용 (한글)` : 긴급 핫픽스 + +--- + +## 🧾 커밋 메시지 규칙 + +```bash +태그: 작업 내용 (한글) + +예: +feat: 로그인 화면 UI 구현 +fix: 바우처 미표시 버그 수정 +``` -## Learn More +--- -To learn more about Next.js, take a look at the following resources: +### ✅ 주요 태그 -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +| 태그 | 의미 | +|------|------| +| `Feat` | 기능 추가 | +| `Fix` | 버그 수정 | +| `Style` | 스타일, 포맷팅 | +| `Refactor` | 코드 리팩토링 | +| `Chore` | 설정, 의존성 작업 | +| `Docs` | 문서 작업 | +| `Test` | 테스트 코드 추가 | -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +--- -## Deploy on Vercel +### 🏷️ 라벨 체계 -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +| 라벨 | 설명 | +|------|--------| +| `FEAT` | 기능 추가 관련 PR/이슈 | +| `FIX` | 버그 수정 관련 | +| `STYLE` | UI/스타일/레이아웃 관련 | +| `REFACTOR` | 리팩토링 관련 | +| `CHORE` | 기타 설정 및 패키지 | +| `TEST` | 테스트 코드 작업 | +| `DOCS` | 문서/주석 관련 | -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +--- diff --git a/app/MainLayout.tsx b/app/MainLayout.tsx new file mode 100644 index 0000000..c6ba113 --- /dev/null +++ b/app/MainLayout.tsx @@ -0,0 +1,14 @@ +"use client"; + +import type React from "react" +import { usePathname } from "next/navigation" + +// 클라이언트 컴포넌트 +export function MainLayout({ children }: { children: React.ReactNode }) { + "use client" + + const pathname = usePathname() + const isAdmin = pathname.startsWith("/admin") + + return
{children}
+} \ No newline at end of file diff --git a/app/dashboard/api/fetch-notice-preview.ts b/app/dashboard/api/fetch-notice-preview.ts new file mode 100644 index 0000000..d10fc9c --- /dev/null +++ b/app/dashboard/api/fetch-notice-preview.ts @@ -0,0 +1,41 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import { getCookie } from "@/lib/cookies"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export interface NoticePreview { + id: number; + title: string; + content: string; + createdAt: string; + isNew: boolean; +} + +export async function fetchNoticePreview(limit: number = 3): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/notice?page=0`, { + method: "GET", + credentials: "include", + }); + + if (!res.ok) throw new Error("공지사항을 불러오는 데 실패했습니다."); + + const data = await res.json(); + const raw = data.result.content || []; + + const now = new Date(); + const formattedNotices = raw.map((n: any) => { + const createdAt = new Date(n.createdAt); + const diffInDays = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24); + + return { + id: n.id, + title: n.title, + content: n.content, + createdAt: n.createdAt, + isNew: diffInDays <= 3, + }; + }); + + return formattedNotices.slice(0, limit); +} diff --git a/app/dashboard/api/fetch-recent-transactions.ts b/app/dashboard/api/fetch-recent-transactions.ts new file mode 100644 index 0000000..cc6dcf9 --- /dev/null +++ b/app/dashboard/api/fetch-recent-transactions.ts @@ -0,0 +1,31 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export interface Transaction { + id: number; + type: string; + amount: number; + createdAt: string; + displayDescription?: string; +} + +export async function fetchRecentTransactions(limit: number = 3): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/wallet/transactions`, { + credentials: "include", + }); + + if (!res.ok) { + throw new Error("거래내역 요청 실패"); + } + + const data = await res.json(); + console.log(data); + + if (!data.isSuccess) { + throw new Error(data.message || "응답 실패"); + } + + return data.result.slice(0, limit); +} diff --git a/app/dashboard/api/wallet-info.ts b/app/dashboard/api/wallet-info.ts new file mode 100644 index 0000000..0329602 --- /dev/null +++ b/app/dashboard/api/wallet-info.ts @@ -0,0 +1,21 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import { fetchWithAuth } from "@/lib/fetchWithAuth"; +import {getCookie} from "@/lib/cookies"; + +const API_URL = getApiUrl(); + +export async function fetchWalletInfo() { + const res = await fetchWithAuth(`${API_URL}/api/users/wallet/balance`, { + method: "GET", + credentials: "include", + }); + + if (!res.ok) throw new Error("지갑 정보를 불러오지 못했습니다."); + + const data = await res.json(); + + const parsedResult = + typeof data.result === "string" ? JSON.parse(data.result) : data.result; + + return parsedResult; +} diff --git a/app/dashboard/components/DashboardHeader.tsx b/app/dashboard/components/DashboardHeader.tsx new file mode 100644 index 0000000..de1d73f --- /dev/null +++ b/app/dashboard/components/DashboardHeader.tsx @@ -0,0 +1,40 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Bell, User } from "lucide-react" +import Image from "next/image" +import { useRouter } from "next/navigation" + +export default function HeaderSection() { + const router = useRouter() + return ( +
+
+
+ +
+ +
+ Tokkit Logo +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/app/dashboard/components/NoticeSection.tsx b/app/dashboard/components/NoticeSection.tsx new file mode 100644 index 0000000..6e6e721 --- /dev/null +++ b/app/dashboard/components/NoticeSection.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; + +interface Notice { + id: number; + title: string; + content: string; + createdAt: string; + isNew: boolean; +} + +interface NoticesSectionProps { + notices: Notice[]; + currentNotice: number; + onNoticeChange: (index: number) => void; +} + +export default function NoticesSection({ + notices, + currentNotice, + onNoticeChange, +}: NoticesSectionProps) { + const router = useRouter(); + const now = new Date(); + + return ( + +
+

+ + 공지사항 +

+ +
+ +
+ + {notices.map((notice, index) => ( + router.push(`/notice/${notice.id}?page=1`)} + > +
+
+
+ {notice.isNew && ( + + )} +

+ {notice.title} +

+
+ + {notice.createdAt.slice(0, 10)} + +
+

+ {notice.content} +

+
+
+ ))} +
+ +
+ {notices.map((_, index) => ( +
+
+
+ ); +} diff --git a/app/dashboard/components/PaymentButton.tsx b/app/dashboard/components/PaymentButton.tsx new file mode 100644 index 0000000..67bd725 --- /dev/null +++ b/app/dashboard/components/PaymentButton.tsx @@ -0,0 +1,25 @@ +"use client" + +import { motion } from "framer-motion" +import { CreditCard } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" + +export default function FloatingPaymentButton() { + const router = useRouter() + + return ( +
+ + + +
+ ) +} diff --git a/app/dashboard/components/QuickMenu.tsx b/app/dashboard/components/QuickMenu.tsx new file mode 100644 index 0000000..a4475b7 --- /dev/null +++ b/app/dashboard/components/QuickMenu.tsx @@ -0,0 +1,55 @@ +"use client" + +import { FileText, History, MapPin } from "lucide-react" +import { motion } from "framer-motion" +import { useRouter } from "next/navigation" + +export default function QuickMenu() { + const router = useRouter() + + return ( +
+

+ + 빠른 메뉴 +

+
+ router.push("/vouchers")} + > +
+ +
+

바우처

+
+ + router.push("/my-vouchers")} + > +
+ +
+

내 바우처

+
+ + router.push("/offline-stores")} + > +
+ +
+

가맹점

+
+
+
+ ) +} diff --git a/app/dashboard/components/RecentTransactions.tsx b/app/dashboard/components/RecentTransactions.tsx new file mode 100644 index 0000000..481981c --- /dev/null +++ b/app/dashboard/components/RecentTransactions.tsx @@ -0,0 +1,77 @@ +// 안 써서 삭제해도 되는 코드 +"use client" + +import { motion } from "framer-motion" +import Image from "next/image" +import { ChevronRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" + +interface Transaction { + id: string + merchant: string + amount: number + date: string + icon: string + color: string +} + +interface RecentTransactionsProps { + data: Transaction[] +} + +export default function RecentTransactions({ data }: RecentTransactionsProps) { + const router = useRouter() + + return ( + +
+

+ + 최근 거래 내역 +

+ +
+ +
+ {data.map((tx) => ( + router.push(`/wallet/transactions/${tx.id}`)} + > +
+
+
+ {tx.merchant} +
+
+
+

{tx.merchant}

+

{tx.date}

+
+
+
0 ? "text-[#10B981]" : "text-[#111827]"}`}> + {tx.amount > 0 ? "+" : ""} + {tx.amount.toLocaleString()}원 +
+
+ ))} +
+
+ ) +} diff --git a/app/dashboard/components/WalletCard.tsx b/app/dashboard/components/WalletCard.tsx new file mode 100644 index 0000000..3efe3a0 --- /dev/null +++ b/app/dashboard/components/WalletCard.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Wallet, ArrowRight } from "lucide-react"; +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; + +interface WalletCardProps { + userName: string; + accountNumber: string; + tokenBalance: number; +} + +export default function WalletCard({ + userName, + accountNumber, + tokenBalance, +}: WalletCardProps) { + const router = useRouter(); + + return ( + router.push("/wallet")} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + > +
+
+
+ +
+
+
+ +
+
+

+ {userName} 님의 지갑 +

+

{accountNumber}

+
+
+ +
+
+

토큰 잔액

+
+

+ {tokenBalance.toLocaleString()} +

+

+
+
+ +
+ + +
+
+
+ + ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..d7e4c1c --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,198 @@ +"use client" + +import React, { useState, useEffect, useRef, useCallback } from "react" +import { useRouter } from "next/navigation" +import { fetchWalletInfo } from "@/app/dashboard/api/wallet-info" + +import { Button } from "@/components/ui/button" +import { ChevronRight } from "lucide-react" + +import HeaderSection from "@/app/dashboard/components/DashboardHeader" +import WalletCard from "./components/WalletCard" +import QuickMenu from "@/app/dashboard/components/QuickMenu" +import NoticesSection from "@/app/dashboard/components/NoticeSection" +import FloatingPaymentButton from "@/app/dashboard/components/PaymentButton" +import TransactionList from "@/app/wallet/components/common/TransactionList" +import { fetchNoticePreview, NoticePreview } from "@/app/dashboard/api/fetch-notice-preview" +import type { Transaction as ApiTransaction } from "@/app/dashboard/api/fetch-recent-transactions" +import { fetchRecentTransactions } from "@/app/dashboard/api/fetch-recent-transactions" +import { Transaction } from "@/app/wallet/api/fetch-transactions" +import {EventSourcePolyfill} from "event-source-polyfill"; +import {getCookie} from "@/lib/cookies"; +import {getApiUrl} from "@/lib/getApiUrl"; +import NotificationToast from "@/components/common/NotificationToast"; + +export default function DashboardPage() { + const router = useRouter() + const [loading, setLoading] = useState(true) + const [recentTransactions, setRecentTransactions] = useState([]) + const [mounted, setMounted] = useState(false) + const [currentNotice, setCurrentNotice] = useState(0) + const noticeSlideTimerRef = useRef(null) + const [notices, setNotices] = useState([]) + const [walletInfo, setWalletInfo] = useState<{ + name: string + accountNumber: string + tokenBalance: number + } | null>(null) + + const [toastVisible, setToastVisible] = useState(false) + const [toastMessage, setToastMessage] = useState({ title: "", content: "" }) + + const showToast = useCallback((title: string, content: string) => { + setToastMessage({ title, content }) + setToastVisible(true) + setTimeout(() => setToastVisible(false), 4000) + }, []) + + useEffect(() => { + const API_URL = getApiUrl() + const accessToken = getCookie("accessToken") + if (!accessToken) return + + const eventSource = new EventSourcePolyfill(`${API_URL}/api/users/notifications/subscribe`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + withCredentials: false, + heartbeatTimeout: 60000, + }) + + eventSource.addEventListener("notification", (event) => { + try { + const { title, content } = JSON.parse((event as MessageEvent).data) + console.log("📥 알림 수신:", title, content) + showToast(title, content) + } catch (e) { + console.error("알림 파싱 오류", e) + } + }) + + eventSource.onerror = (err) => { + console.error("SSE 오류:", err) + eventSource.close() + } + + return () => { + eventSource.close() + } + }, [showToast]) + + + useEffect(() => { + setMounted(true) + + fetchWalletInfo() + .then((data) => { + setWalletInfo(data) + }) + .catch((err) => { + console.error("지갑 정보 로딩 실패:", err) + }) + + const startNoticeSlide = () => { + if (notices.length === 0) return + noticeSlideTimerRef.current = setInterval(() => { + setCurrentNotice((prev) => (prev + 1) % notices.length) + }, 4000) + } + + startNoticeSlide() + + return () => { + if (noticeSlideTimerRef.current) { + clearInterval(noticeSlideTimerRef.current) + } + } + }, [notices.length]) + + useEffect(() => { + fetchNoticePreview(3) + .then(setNotices) + .catch((err) => { + console.error("공지사항 로딩 실패:", err) + }) + }, []) + + useEffect(() => { + fetchRecentTransactions(3) + .then(setRecentTransactions) + .catch((err) => { + console.error("거래내역 조회 실패:", err) + }) + .finally(() => { + setLoading(false) + }) + }, []) + + const handleNoticeChange = (index: number) => { + if (notices.length === 0) return + + setCurrentNotice(index) + + if (noticeSlideTimerRef.current) { + clearInterval(noticeSlideTimerRef.current) + } + + noticeSlideTimerRef.current = setInterval(() => { + setCurrentNotice((prev) => (prev + 1) % notices.length) + }, 4000) + } + + if (!mounted) return null + + return ( +
+ + + +
+ +

+ + 최근 거래 내역 +

+
+ {loading ? ( +

최근 거래를 불러오는 중...

+ ) : ( + ({ + ...t, + displayDescription: t.displayDescription || "", + })) as Transaction[] + } + limit={3} + /> + )} +
+ +
+
+ +
+ + +
+ ) +} diff --git a/app/global.css b/app/global.css new file mode 100644 index 0000000..aa680f8 --- /dev/null +++ b/app/global.css @@ -0,0 +1,75 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 10%; + --card: 0 0% 100%; + --card-foreground: 0 0% 10%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 10%; + --primary: 38 100% 56%; + --primary-foreground: 0 0% 100%; + --secondary: 0 0% 96%; + --secondary-foreground: 0 0% 10%; + --muted: 0 0% 96%; + --muted-foreground: 0 0% 60%; + --accent: 38 100% 70%; + --accent-foreground: 0 0% 10%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 100%; + --border: 0 0% 88%; + --input: 0 0% 88%; + --ring: 38 100% 70%; + --radius: 1rem; + + } + + +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background-color: #f0f0f0; + border-radius: 10px; +} + + +::-webkit-scrollbar-thumb { + background-color: #cccccc; + border-radius: 10px; +} + + +::-webkit-scrollbar-thumb:hover { + background-color: #aaaaaa; +} + + + + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + + +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..2bf95dd --- /dev/null +++ b/app/globals.css @@ -0,0 +1,58 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + +} + +@layer base { + * body { + @apply bg-white text-gray-800; + } +} diff --git a/app/landing-page/components/CBDCTab.tsx b/app/landing-page/components/CBDCTab.tsx new file mode 100644 index 0000000..611fe71 --- /dev/null +++ b/app/landing-page/components/CBDCTab.tsx @@ -0,0 +1,107 @@ +"use client" + +import { motion, AnimatePresence } from "framer-motion" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import TokenInfoCard from "@/app/landing-page/components/TokenInfoCard" +import { CardInfo } from "@/app/landing-page/data/CardInfoData" + +interface CBDCTabProps { + isOpen: boolean + onClose: () => void + currentCard: number + setCurrentCard: (index: number) => void + cardInfo: CardInfo[] +} + +export default function CBDCTab({ + isOpen, + onClose, + currentCard, + setCurrentCard, + cardInfo, + }: CBDCTabProps) { + const handleSwipe = (direction: number) => { + const newIndex = currentCard + direction + if (newIndex >= 0 && newIndex < cardInfo.length) { + setCurrentCard(newIndex) + } + } + + return ( + +
+ {/* 상단 헤더 */} +
+ +

CBDC 알아보기

+
+ + {/* 카드 영역 */} +
+ + + + + + + {/* 화살표 버튼 */} + {currentCard > 0 && ( + + )} + + {currentCard < cardInfo.length - 1 && ( + + )} +
+ + {/* 인디케이터 */} +
+ {cardInfo.map((_, index) => ( +
+
+
+ ) +} diff --git a/app/landing-page/components/CardDetailMap.tsx b/app/landing-page/components/CardDetailMap.tsx new file mode 100644 index 0000000..7ec329c --- /dev/null +++ b/app/landing-page/components/CardDetailMap.tsx @@ -0,0 +1,14 @@ +"use client" + +import React from "react" +import CbdcIntroCard from "@/app/landing-page/components/token-detail/CBDCIntroCard" +import QRCodeCard from "@/app/landing-page/components/token-detail/QRCodeCard" +import FastTransferCard from "@/app/landing-page/components/token-detail/FastTransferCard" +import FinanceAccessCard from "@/app/landing-page/components/token-detail/FinanceAccessCard" + +export const cardDetailMap: Record = { + CBDC: , + SECURE: , + FAST: , + INCLUSION: , +} diff --git a/app/landing-page/components/LandingMain.tsx b/app/landing-page/components/LandingMain.tsx new file mode 100644 index 0000000..e58f1be --- /dev/null +++ b/app/landing-page/components/LandingMain.tsx @@ -0,0 +1,102 @@ +"use client" + +import { motion } from "framer-motion" +import Image from "next/image" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import PullHandle from "@/app/landing-page/components/PullHandle" + +interface LandingMainProps { + onOpenTab: () => void + isTabOpen: boolean +} + +export default function LandingMain({ onOpenTab, isTabOpen }: LandingMainProps) { + const router = useRouter() + + return ( + // 전체 메인 영역 (로고, 슬로건, 버튼, 마스코트) + + {/* 🐰 마스코트 이미지 */} +
+ + Tokkit 마스코트 + + + {/* 💬 서비스명 & 슬로건 */} + + Tokkit + + + + 중앙은행 디지털 화폐(CBDC)로
스마트하고 안전한 금융 생활을 시작하세요 +
+ + {/* 🔘 로그인 / 회원가입 / 가맹점 버튼 */} + + + + + +
+ +
+
+
+ + {/* ☰ 슬라이드 탭 열기 핸들 */} +
+ +
+
+ ) +} diff --git a/app/landing-page/components/PullHandle.tsx b/app/landing-page/components/PullHandle.tsx new file mode 100644 index 0000000..14d9886 --- /dev/null +++ b/app/landing-page/components/PullHandle.tsx @@ -0,0 +1,28 @@ +"use client" + +import { motion } from "framer-motion" +import { ChevronLeft } from "lucide-react" + +interface PullHandleProps { + onPull: () => void +} + +export default function PullHandle({ onPull }: PullHandleProps) { + return ( + { + if (info.offset.x < -15) { + onPull() + } + }} + > + + + ) +} diff --git a/app/landing-page/components/TokenInfoCard.tsx b/app/landing-page/components/TokenInfoCard.tsx new file mode 100644 index 0000000..5e0c161 --- /dev/null +++ b/app/landing-page/components/TokenInfoCard.tsx @@ -0,0 +1,59 @@ +"use client" + +import { motion } from "framer-motion" +import { ArrowRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import { cardDetailMap } from "@/app/landing-page/components/CardDetailMap" + +interface TokenInfoCardProps { + cardKey: string + title: string + description: string + color: string + icon: string +} + +export default function TokenInfoCard({ + cardKey, + title, + description, + color, + icon, + }: TokenInfoCardProps) { + const detail = cardDetailMap[cardKey] + + return ( + + {/* 상단 색 바 */} +
+ +
+ {/* 제목 및 아이콘 */} +
+
+ {icon} +
+

{title}

+
+ + {/* 설명 텍스트 */} +

+ {description} +

+ + {/* 외부 detail 컴포넌트 삽입 */} + {detail && ( +
+ {detail} +
+ )} +
+ + ) +} diff --git a/app/landing-page/components/token-detail/CBDCIntroCard.tsx b/app/landing-page/components/token-detail/CBDCIntroCard.tsx new file mode 100644 index 0000000..f03cddf --- /dev/null +++ b/app/landing-page/components/token-detail/CBDCIntroCard.tsx @@ -0,0 +1,21 @@ +export default function CbdcIntroCard() { + return ( +
+
+
+ Tokkit 마스코트 +
+
+

Tokkit 토큰

+

+ 안전하고 빠른
디지털 화폐의 시작 +

+
+
+
+ ) +} diff --git a/app/landing-page/components/token-detail/FastTransferCard.tsx b/app/landing-page/components/token-detail/FastTransferCard.tsx new file mode 100644 index 0000000..ea71ed7 --- /dev/null +++ b/app/landing-page/components/token-detail/FastTransferCard.tsx @@ -0,0 +1,12 @@ +export default function FastTransferCard({ icon }: { icon: string }) { + return ( +
+
+
+ fast-transfer-image +
+

24시간 365일 실시간 송금

+
+
+ ) +} diff --git a/app/landing-page/components/token-detail/FinanceAccessCard.tsx b/app/landing-page/components/token-detail/FinanceAccessCard.tsx new file mode 100644 index 0000000..68a2955 --- /dev/null +++ b/app/landing-page/components/token-detail/FinanceAccessCard.tsx @@ -0,0 +1,12 @@ +export default function FinanceAccessCard({ icon }: { icon: string }) { + return ( +
+
+
+ fast-transfer-image +
+

모두를 위한 금융 서비스

+
+
+ ) +} diff --git a/app/landing-page/components/token-detail/QRCodeCard.tsx b/app/landing-page/components/token-detail/QRCodeCard.tsx new file mode 100644 index 0000000..14ba192 --- /dev/null +++ b/app/landing-page/components/token-detail/QRCodeCard.tsx @@ -0,0 +1,12 @@ +export default function QRCodeCard() { + return ( +
+
+
+ QR 코드 +
+
+
QR 결제
+
+ ) +} diff --git a/app/landing-page/data/CardInfoData.ts b/app/landing-page/data/CardInfoData.ts new file mode 100644 index 0000000..3f4ba40 --- /dev/null +++ b/app/landing-page/data/CardInfoData.ts @@ -0,0 +1,42 @@ +export interface CardInfo { + cardKey: string + title: string + description: string + color: string + icon: string +} + +export const cardInfoData: CardInfo[] = [ + { + cardKey: "CBDC", + title: "CBDC란?", + description: + "중앙은행 디지털 화폐(CBDC)는 중앙은행이 발행하는 디지털 형태의 법정 화폐입니다. 실물 화폐와 동일한 가치를 지니며, 블록체인 기술을 기반으로 합니다.", + color: "#FFB020", + icon: "💰", + }, + { + cardKey: "SECURE", + title: "안전한 거래", + description: + "CBDC는 중앙은행의 신뢰를 바탕으로 안전하고 투명한 거래를 보장합니다. 모든 거래는 암호화되어 보안성이 뛰어납니다.", + color: "#FF4A4A", + icon: "🔒", + }, + { + cardKey: "FAST", + title: "빠른 송금", + description: + "가맹점은 결제 즉시 정산이 이루어져 자금 흐름이 원활해집니다. 기존 카드 결제 대비 정산 기간이 대폭 단축됩니다.", + color: "#3B82F6", + icon: "⚡", + }, + { + cardKey: "INCLUSION", + title: "금융 포용성", + description: + "지역 화폐와 연계된 바우처 시스템으로 지역 소비를 촉진하고 지역 경제를 활성화합니다.", + color: "#10B981", + icon: "🌍", + }, +] diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..76374f8 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,42 @@ +import type React from "react" +import type { Metadata } from "next" +import { Nunito } from "next/font/google" +import "./globals.css" +import { ThemeProvider } from "@/components/theme-provider" +import { MainLayout } from "./MainLayout" + +const nunito = Nunito({ + weight: ["300", "400", "500", "600", "700", "800"], + subsets: ["latin"], +}) + +export const metadata: Metadata = { + title: "Tokkit - 스마트한 금융의 시작", + description: "Tokkit과 함께 더 스마트하고 안전한 금융 서비스를 경험하세요", + generator: 'v0.dev' +} + +export const viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + + + + {children} + + + + ) +} diff --git a/app/login/api/use-login.ts b/app/login/api/use-login.ts new file mode 100644 index 0000000..e1321b1 --- /dev/null +++ b/app/login/api/use-login.ts @@ -0,0 +1,29 @@ +import { getApiUrl } from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export const useLogin = () => { + const login = async (email: string, password: string) => { + const res = await fetch(`${API_URL}/api/users/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + throw new Error("로그인에 실패했습니다."); + } + + const data: { result: { accessToken: string; refreshToken: string } } = await res.json(); + const { accessToken, refreshToken } = data.result; + + // 쿠키에 저장 (예시: 1일 유효) + document.cookie = `accessToken=${accessToken}; path=/; max-age=86400;`; + document.cookie = `refreshToken=${refreshToken}; path=/; max-age=86400;`; + + return { accessToken, refreshToken }; + }; + + return { login }; +}; diff --git a/app/login/components/LoginForm.tsx b/app/login/components/LoginForm.tsx new file mode 100644 index 0000000..f555202 --- /dev/null +++ b/app/login/components/LoginForm.tsx @@ -0,0 +1,96 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Eye, EyeOff } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { useLogin } from "@/app/login/api/use-login" + +export default function LoginForm() { + const router = useRouter() + const { login } = useLogin() + + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [showPassword, setShowPassword] = useState(false) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + + try { + await login(email, password) + router.push("/dashboard") + } catch (err: any) { + alert(err.message ?? "로그인 실패") + } finally { + setLoading(false) + } + } + + return ( +
+
+ + setEmail(e.target.value)} + placeholder="아이디를 입력하세요" + className="h-10 rounded-lg border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] focus-visible:ring-[#FFD485] dark:focus-visible:ring-[#FFB020] focus-visible:ring-offset-0" + required + /> +
+ +
+
+ + +
+
+ setPassword(e.target.value)} + placeholder="비밀번호를 입력하세요" + className="h-10 rounded-lg border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] focus-visible:ring-[#FFD485] dark:focus-visible:ring-[#FFB020] focus-visible:ring-offset-0 pr-10" + required + /> + +
+
+ + +
+ ) +} diff --git a/app/login/components/LoginHeader.tsx b/app/login/components/LoginHeader.tsx new file mode 100644 index 0000000..e3b4f47 --- /dev/null +++ b/app/login/components/LoginHeader.tsx @@ -0,0 +1,26 @@ +"use client" + +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { ArrowLeft } from "lucide-react" +import { motion } from "framer-motion" + +export default function LoginHeader() { + const router = useRouter() + + return ( +
+ + + 로그인 + +
+ ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..a11b19f --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,67 @@ +"use client" + +import { motion } from "framer-motion" +import Image from "next/image" +import MobileLayout from "../mobile-layout" +import LoginHeader from "@/app/login/components/LoginHeader" +import LoginForm from "@/app/login/components/LoginForm" + +export default function LoginPage() { + const pageVariants = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + } + + return ( + + + + +
+
+
+ + Tokkit Logo + + + + + + + +
+
+
+
+
+ ) +} diff --git a/app/merchant/components/MascotImage.tsx b/app/merchant/components/MascotImage.tsx new file mode 100644 index 0000000..dcddd7a --- /dev/null +++ b/app/merchant/components/MascotImage.tsx @@ -0,0 +1,26 @@ +"use client" + +import { motion } from "framer-motion" +import Image from "next/image" + +interface MascotImageProps { + src: string + alt: string + width?: number + height?: number + isAnimating?: boolean + className?: string +} + +export function MascotImage({ src, alt, width = 180, height = 180, isAnimating = false, className ="" }: MascotImageProps) { + return ( + + {alt} + + ) +} diff --git a/app/merchant/dashboard/api/daily-income.ts b/app/merchant/dashboard/api/daily-income.ts new file mode 100644 index 0000000..dbe828f --- /dev/null +++ b/app/merchant/dashboard/api/daily-income.ts @@ -0,0 +1,19 @@ +import {getApiUrl} from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export async function fetchDailyIncome() { + const res = await fetchWithAuth(`${API_URL}/api/merchants/wallet/daily-income`, { + method: "GET", + credentials: "include", + }); + + if (!res.ok) throw new Error("가맹점주 일일 수익 정보를 불러오지 못했습니다."); + + const data = await res.json(); + + const parsedResult = typeof data.result === "string" ? JSON.parse(data.result) : data.result; + + return parsedResult; +} \ No newline at end of file diff --git a/app/merchant/dashboard/api/fetch-merchant-notice-preview.ts b/app/merchant/dashboard/api/fetch-merchant-notice-preview.ts new file mode 100644 index 0000000..988413a --- /dev/null +++ b/app/merchant/dashboard/api/fetch-merchant-notice-preview.ts @@ -0,0 +1,41 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import { getCookie } from "@/lib/cookies"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export interface NoticePreview { + id: number; + title: string; + content: string; + createdAt: string; + isNew: boolean; +} + +export async function fetchNoticePreview(limit: number = 3): Promise { + const res = await fetchWithAuth(`${API_URL}/api/merchants/notice?page=0`, { + method: "GET", + credentials: "include", + }); + + if (!res.ok) throw new Error("공지사항을 불러오는 데 실패했습니다."); + + const data = await res.json(); + const raw = data.result.content || []; + + const now = new Date(); + const formattedNotices = raw.map((n: any) => { + const createdAt = new Date(n.createdAt); + const diffInDays = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24); + + return { + id: n.id, + title: n.title, + content: n.content, + createdAt: n.createdAt, + isNew: diffInDays <= 3, + }; + }); + + return formattedNotices.slice(0, limit); +} diff --git a/app/merchant/dashboard/api/merchant-recent-transactions.ts b/app/merchant/dashboard/api/merchant-recent-transactions.ts new file mode 100644 index 0000000..e8bd517 --- /dev/null +++ b/app/merchant/dashboard/api/merchant-recent-transactions.ts @@ -0,0 +1,52 @@ +import {getApiUrl} from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export interface MerchantTransaction { + id: number; + type: string; + amount: number; + createdAt: string; + displayDescription: string; +} + +export async function fetchMerchantRecentTransactions(limit: number = 3): Promise { + const res = await fetchWithAuth(`${API_URL}/api/merchants/wallet/transactions/recent`, { + method: "GET", + credentials: "include", + }); + + if (!res.ok) throw new Error("가맹점주 최근 거래 내역을 불러오지 못했습니다."); + + const data = await res.json(); + + if (!data.isSuccess) throw new Error(data.message || "응답 실패"); + + return data.result.slice(0, limit).map( + (item: any) => ({ + id: item.id, + type: item.type, + amount: item.amount, + createdAt: item.createdAt, + displayDescription: item.displayDescription, + }) + ) +} + +export default async function fetchMerchantTransactions(): Promise { + const res = await fetchWithAuth(`${API_URL}/api/merchants/wallet/transactions`, { + method: "GET", + credentials: "include", + }); + + if (!res.ok) throw new Error("가맹점주 거래 내역을 불러오지 못했습니다."); + + const data = await res.json(); + + if (!data.isSuccess) throw new Error(data.message || "응답 실패"); + + console.log(data.result) + + return data.result; +} \ No newline at end of file diff --git a/app/merchant/dashboard/api/merchant-wallet-info.ts b/app/merchant/dashboard/api/merchant-wallet-info.ts new file mode 100644 index 0000000..299ea19 --- /dev/null +++ b/app/merchant/dashboard/api/merchant-wallet-info.ts @@ -0,0 +1,20 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import { fetchWithAuth } from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export async function fetchMerchantWalletInfo() { + const res = await fetchWithAuth(`${API_URL}/api/merchants/wallet/balance`, { + method: "GET", + credentials: "include", + }); + + if (!res.ok) throw new Error("가맹점주 지갑 정보를 불러오지 못했습니다."); + + const data = await res.json(); + + const parsedResult = + typeof data.result === "string" ? JSON.parse(data.result) : data.result; + + return parsedResult; +} \ No newline at end of file diff --git a/app/merchant/dashboard/components/MerchantHeader.tsx b/app/merchant/dashboard/components/MerchantHeader.tsx new file mode 100644 index 0000000..c29244e --- /dev/null +++ b/app/merchant/dashboard/components/MerchantHeader.tsx @@ -0,0 +1,39 @@ +"use client" + +import { Bell, User } from "lucide-react" +import Image from "next/image" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" + +export function MerchantHeader() { + const router = useRouter() + return ( +
+
+
+ +
+ +
+ Tokkit Logo +
+ + +
+
+ ) +} diff --git a/app/merchant/dashboard/components/MerchantRecentTransaction.tsx b/app/merchant/dashboard/components/MerchantRecentTransaction.tsx new file mode 100644 index 0000000..cb0f42b --- /dev/null +++ b/app/merchant/dashboard/components/MerchantRecentTransaction.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useRouter } from "next/navigation" +import { ChevronRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import TransactionList from "./TransactionList" +import { MerchantTransaction } from "../api/merchant-recent-transactions" + +interface Props { + transactions: MerchantTransaction[] + loading: boolean +} + +export default function MerchantRecentTransaction({ transactions, loading }: Props) { + const router = useRouter() + + return ( + <> +
+
+

최근 거래 내역

+
+
+ {loading ? ( +

최근 거래를 불러오는 중...

+ ) : ( + ({ + ...t, + description: t.displayDescription || '' + }))} + limit={3} + /> + )} +
+ +
+
+ + ) +} diff --git a/app/merchant/dashboard/components/NoticeSection.tsx b/app/merchant/dashboard/components/NoticeSection.tsx new file mode 100644 index 0000000..5d696d9 --- /dev/null +++ b/app/merchant/dashboard/components/NoticeSection.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; + +interface Notice { + id: number; + title: string; + content: string; + createdAt: string; + isNew: boolean; +} + +interface NoticesSectionProps { + notices: Notice[]; + currentNotice: number; + onNoticeChange: (index: number) => void; +} + +export default function NoticesSection({ + notices, + currentNotice, + onNoticeChange, +}: NoticesSectionProps) { + const router = useRouter(); + const now = new Date(); + + return ( + +
+

+ + 공지사항 +

+ + +
+ +
+ + {notices.map((notice, index) => ( + router.push(`/merchant/notice/${notice.id}?page=1`)} + > +
+
+
+ {notice.isNew && ( + + )} +

+ {notice.title} +

+
+ + {notice.createdAt.slice(0, 10)} + +
+

+ {notice.content} +

+
+
+ ))} +
+ +
+ {notices.map((_, index) => ( +
+
+
+ ); +} diff --git a/app/merchant/dashboard/components/SalesStatistics.tsx b/app/merchant/dashboard/components/SalesStatistics.tsx new file mode 100644 index 0000000..cadb35e --- /dev/null +++ b/app/merchant/dashboard/components/SalesStatistics.tsx @@ -0,0 +1,55 @@ +"use client" + +import { Card, CardContent } from "@/components/ui/card" +import Image from "next/image" +import { motion } from "framer-motion" + +interface SalesStatisticsProps { + dailyIncome: number + isLoading: boolean +} + +export function SalesStatistics({ dailyIncome, isLoading }: SalesStatisticsProps) { + return ( +
+
+
+

매출 통계

+
+ +
+ {[ + { label: "오늘 매출", value: dailyIncome, delay: 0.1, bg: "#EEF2FF", icon: "/images/merchant-bunny.png" }, + ].map((item, i) => ( + + + + {/* 아이콘 */} +
+ {item.label} +
+ + {/* 텍스트 */} +
+

{item.label}

+

+ {isLoading ? "-" : item.value.toLocaleString()} TKT +

+
+
+
+ +
+ ))} +
+
+ ) +} diff --git a/app/merchant/dashboard/components/TransactionList.tsx b/app/merchant/dashboard/components/TransactionList.tsx new file mode 100644 index 0000000..db34c1a --- /dev/null +++ b/app/merchant/dashboard/components/TransactionList.tsx @@ -0,0 +1,66 @@ +import {useRouter} from "next/navigation"; +import {Card, CardContent} from "@/components/ui/card"; +import TransactionCardContent from "@/components/common/TransactionCardContent"; + +interface MerchantTransaction { + id?: number; + type: string; + amount: number; + displayDescription: string; + createdAt: string; +} + +interface MerchantTransactionListProps { + label?: string; + transactions: MerchantTransaction[]; + limit?: number; +} + +export default function TransactionList({ + label, + transactions, + limit, + }: MerchantTransactionListProps) { + const router = useRouter(); + + const data = limit ? transactions.slice(0, limit) : transactions; + + return ( +
+ {label && ( +

{label}

+ )} + {data.length > 0 ? ( + data.map((tx, index) => { + if (typeof tx.id !== "number") { + return null; + } + + const handleClick = () => { + router.push(`/merchant/wallet/totaltransaction/${tx.id}`); + }; + + return ( + + + + + + ); + }) + ) : ( +
+ 표시할 거래 내역이 없습니다. +
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/merchant/dashboard/components/VoucherSearch.tsx b/app/merchant/dashboard/components/VoucherSearch.tsx new file mode 100644 index 0000000..d27e01d --- /dev/null +++ b/app/merchant/dashboard/components/VoucherSearch.tsx @@ -0,0 +1,47 @@ +"use client" + +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import Image from "next/image" + + +export function VoucherSearch() { + const router = useRouter() + + return ( +
+
+
+

바우처 조회

+
+ + + + +
+
+ 바우처 조회 +
+
+

바우처 조회

+

가맹점과 연관된 바우처 확인

+
+
+ +
+
+
+
+ ) +} diff --git a/app/merchant/dashboard/components/WalletCard.tsx b/app/merchant/dashboard/components/WalletCard.tsx new file mode 100644 index 0000000..a38acba --- /dev/null +++ b/app/merchant/dashboard/components/WalletCard.tsx @@ -0,0 +1,72 @@ +"use client" + +import { Wallet, ArrowRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import Image from "next/image" + +interface WalletCardProps { + storeName: string + accountNumber: string + tokenBalance: number + depositBalance: number + isLoading: boolean + onClick: () => void + onConvertClick: () => void +} + +export function WalletCard({ + storeName, + accountNumber, + tokenBalance, + depositBalance, + isLoading, + onClick, + onConvertClick, + }: WalletCardProps) { + return ( +
+
+
+
+ +
+
+
+ +
+
+

{storeName}

+

{accountNumber}

+
+
+ +
+
+

토큰 잔액

+
+

+ {isLoading ? "-" : tokenBalance.toLocaleString()} +

+

TKT

+
+
+
+ +
+
+
+
+ ) +} diff --git a/app/merchant/dashboard/page.tsx b/app/merchant/dashboard/page.tsx new file mode 100644 index 0000000..65db3ce --- /dev/null +++ b/app/merchant/dashboard/page.tsx @@ -0,0 +1,147 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { useMobile } from "@/hooks/use-mobile" +import { useRouter } from "next/navigation" +import {MerchantHeader} from "@/app/merchant/dashboard/components/MerchantHeader"; +import {WalletCard} from "@/app/merchant/dashboard/components/WalletCard"; +import {SalesStatistics} from "@/app/merchant/dashboard/components/SalesStatistics"; +import {VoucherSearch} from "@/app/merchant/dashboard/components/VoucherSearch"; +import {fetchMerchantWalletInfo} from "@/app/merchant/dashboard/api/merchant-wallet-info"; +import {fetchDailyIncome} from "@/app/merchant/dashboard/api/daily-income"; +import {fetchMerchantRecentTransactions, MerchantTransaction} from "./api/merchant-recent-transactions"; +import MerchantRecentTransaction from "@/app/merchant/dashboard/components/MerchantRecentTransaction"; +import NoticesSection from "@/app/merchant/dashboard/components/NoticeSection"; +import { fetchNoticePreview, NoticePreview } from "@/app/merchant/dashboard/api/fetch-merchant-notice-preview" + +export default function MerchantDashboardPage() { + const isMobile = useMobile() + const router = useRouter() + const [loading, setLoading] = useState(true); + const [mounted, setMounted] = useState(false); + const [isLoading, setIsLoading] = useState(true) + const [dailyIncome, setDailyIncome] = useState<{ + dailyIncome: number; + }>({ dailyIncome: 0 }) + const [recentMerchantTransactions, setRecentMerchantTransactions] = useState([]) + const [currentNotice, setCurrentNotice] = useState(0) + const noticeSlideTimerRef = useRef(null) + const [walletInfo, setWalletInfo] = useState<{ + storeName: string; + accountNumber: string; + tokenBalance: number; + depositBalance: number; + } | null>(null) + const [notices, setNotices] = useState([]) + + useEffect(() => { + setMounted(true); + + fetchMerchantWalletInfo() + .then((data) => { + setWalletInfo(data); + }) + .catch((err) => { + console.error("지갑 정보 로딩 실패:", err); + }); + + fetchDailyIncome() + .then((data) => { + setDailyIncome(data); + setIsLoading(false); + }) + .catch((err) => { + console.error("일일 통계 로딩 실패:", err); + }); + + fetchMerchantRecentTransactions(3) + .then(setRecentMerchantTransactions) + .catch((err) => { + console.error("거래내역 조회 실패:", err); + }) + .finally(() => { + setLoading(false); + }); + }, []) + + // 공지사항 자동 슬라이드 설정 + useEffect(() => { + const startNoticeSlide = () => { + noticeSlideTimerRef.current = setInterval(() => { + setCurrentNotice((prev) => (prev + 1) % notices.length) + }, 4000) // 4초마다 슬라이드 + } + + startNoticeSlide() + + return () => { + if (noticeSlideTimerRef.current) { + clearInterval(noticeSlideTimerRef.current) + } + } + }, [notices.length]) + + useEffect(() => { + fetchNoticePreview(3) + .then(setNotices) + .catch((err) => { + console.error("공지사항 로딩 실패:", err); + }); + }, []); + + // 수동으로 공지사항 변경 시 타이머 재설정 + const handleNoticeChange = (index: number) => { + setCurrentNotice(index) + + if (noticeSlideTimerRef.current) { + clearInterval(noticeSlideTimerRef.current) + } + + noticeSlideTimerRef.current = setInterval(() => { + setCurrentNotice((prev) => (prev + 1) % notices.length) + }, 4000) + } + + return ( +
+ {/* 헤더 */} + + + {/* 메인 컨텐츠 */} +
+ {/* 전자지갑 카드 */} + router.push("/merchant/wallet")} + onConvertClick={() => router.push("/merchant/wallet/convert")} + /> + + {/* 매출 통계 카드 */} + + + {/* 바우처 조회 */} + + + {/* 최근 거래내역 조회 */} + + + {/* 공지사항 */} + +
+
+ ) +} diff --git a/app/merchant/login/api/merchant-login.ts b/app/merchant/login/api/merchant-login.ts new file mode 100644 index 0000000..965f9f6 --- /dev/null +++ b/app/merchant/login/api/merchant-login.ts @@ -0,0 +1,32 @@ +import { setCookie } from "@/lib/cookies" +import { getApiUrl } from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export interface MerchantLoginRequest { + businessNumber: string + password: string +} + +export async function loginMerchant(data: MerchantLoginRequest): Promise { + const res = await fetch(`${API_URL}/api/merchants/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(data), + }) + + if (!res.ok) { + const error = await res.json().catch(() => ({})) + const message = error?.message || '로그인에 실패했습니다.' + throw new Error(message) + } + + const dataJson = await res.json() + const { accessToken, refreshToken } = dataJson.result + + setCookie("accessToken", accessToken) + setCookie("refreshToken", refreshToken, 3600 * 24 * 7) // 7일 +} \ No newline at end of file diff --git a/app/merchant/login/components/MerchantLoginForm.tsx b/app/merchant/login/components/MerchantLoginForm.tsx new file mode 100644 index 0000000..e4eca04 --- /dev/null +++ b/app/merchant/login/components/MerchantLoginForm.tsx @@ -0,0 +1,109 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" +import { Eye, EyeOff } from "lucide-react" +import { loginMerchant } from "@/app/merchant/login/api/merchant-login" + +export default function MerchantLoginForm() { + const router = useRouter() + const [businessId, setBusinessId] = useState("") + const [password, setPassword] = useState("") + const [showPassword, setShowPassword] = useState(false) + const [loading, setLoading] = useState(false) + + const formatBusinessNumber = (value: string) => { + const onlyNums = value.replace(/\D/g, "").slice(0, 10) + if (onlyNums.length >= 6) { + return `${onlyNums.slice(0, 3)}-${onlyNums.slice(3, 5)}-${onlyNums.slice(5)}` + } else if (onlyNums.length >= 4) { + return `${onlyNums.slice(0, 3)}-${onlyNums.slice(3)}` + } else { + return onlyNums + } + } + + const handleBusinessIdChange = (e: React.ChangeEvent) => { + const formatted = formatBusinessNumber(e.target.value) + setBusinessId(formatted) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + + try { + await loginMerchant({ + businessNumber: businessId, + password: password, + }) + router.push("/merchant/dashboard") + } catch (err: any) { + alert(err.message || "로그인 중 문제가 발생했습니다.") + } finally { + setLoading(false) + } + } + + return ( +
+
+ + +
+ +
+
+ + +
+
+ setPassword(e.target.value)} + placeholder="비밀번호를 입력하세요" + className="h-12 rounded-xl border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] focus-visible:ring-[#FFD485] dark:focus-visible:ring-[#FFB020] focus-visible:ring-offset-0 pr-10" + required + /> + +
+
+ + +
+ ) +} diff --git a/app/merchant/login/components/MerchantLoginHeader.tsx b/app/merchant/login/components/MerchantLoginHeader.tsx new file mode 100644 index 0000000..de1f280 --- /dev/null +++ b/app/merchant/login/components/MerchantLoginHeader.tsx @@ -0,0 +1,26 @@ +"use client" + +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { motion } from "framer-motion" +import { ArrowLeft } from "lucide-react" +import Image from "next/image" + +export default function MerchantLoginHeader() { + const router = useRouter() + return ( +
+ + + 가맹점주 로그인 + +
+ ) +} diff --git a/app/merchant/login/components/MerchantLogo.tsx b/app/merchant/login/components/MerchantLogo.tsx new file mode 100644 index 0000000..894a507 --- /dev/null +++ b/app/merchant/login/components/MerchantLogo.tsx @@ -0,0 +1,23 @@ +"use client" + +import { motion } from "framer-motion" +import Image from "next/image" + +export default function MerchantLogo() { + return ( + + Tokkit Merchant Logo + + ) +} diff --git a/app/merchant/login/components/SwitchToUserButton.tsx b/app/merchant/login/components/SwitchToUserButton.tsx new file mode 100644 index 0000000..8a40f5b --- /dev/null +++ b/app/merchant/login/components/SwitchToUserButton.tsx @@ -0,0 +1,23 @@ +"use client" + +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" + +export default function SwitchToUserButton() { + const router = useRouter() + return ( + + + + ) +} diff --git a/app/merchant/login/page.tsx b/app/merchant/login/page.tsx new file mode 100644 index 0000000..86aa49d --- /dev/null +++ b/app/merchant/login/page.tsx @@ -0,0 +1,37 @@ +"use client" + +import { motion } from "framer-motion" +import MerchantLoginHeader from "./components/MerchantLoginHeader" +import MerchantLogo from "./components/MerchantLogo" +import MerchantLoginForm from "./components/MerchantLoginForm" +import SwitchToUserButton from "./components/SwitchToUserButton" + +export default function MerchantLoginPage() { + const pageVariants = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + } + + return ( + + +
+
+ + + + + +
+
+
+ ) +} diff --git a/app/merchant/mypage/api/merchant-info.ts b/app/merchant/mypage/api/merchant-info.ts new file mode 100644 index 0000000..4360ac4 --- /dev/null +++ b/app/merchant/mypage/api/merchant-info.ts @@ -0,0 +1,29 @@ +import {getApiUrl} from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export interface MerchantInfo { + id: number; + name: string; + storeName: string; + email: string; + phoneNumber: string; + businessNumber: string; + roadAddress: string; + storeCategory: string; +} + +export async function getMerchantInfo() { + const res = await fetchWithAuth(`${API_URL}/api/merchants/info`, { + method: "GET", + credentials: "include", + }); + + if (!res.ok) { + throw new Error("가맹점주 정보를 불러오지 못했습니다."); + } + + const data = await res.json(); + return data.result; +} \ No newline at end of file diff --git a/app/merchant/mypage/api/merchant-logout.ts b/app/merchant/mypage/api/merchant-logout.ts new file mode 100644 index 0000000..3b67209 --- /dev/null +++ b/app/merchant/mypage/api/merchant-logout.ts @@ -0,0 +1,17 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export const merchantLogout = async () => { + const res = await fetchWithAuth(`${API_URL}/api/merchants/logout`, { + method: "POST", + credentials: "include", + }); + + if (!res.ok) { + throw new Error("로그아웃에 실패했습니다."); + } + + return res; +} \ No newline at end of file diff --git a/app/merchant/mypage/change-password/api/password-update.ts b/app/merchant/mypage/change-password/api/password-update.ts new file mode 100644 index 0000000..9b1448e --- /dev/null +++ b/app/merchant/mypage/change-password/api/password-update.ts @@ -0,0 +1,25 @@ +import {getApiUrl} from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export async function updatePassword(password: string, newPassword: string) { + const res = await fetchWithAuth(`${API_URL}/api/merchants/password-update`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + password, + newPassword, + }) + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.message || '비밀번호 변경에 실패했습니다.'); + } + + return true; +} \ No newline at end of file diff --git a/app/merchant/mypage/change-password/components/ChangePasswordCardHeader.tsx b/app/merchant/mypage/change-password/components/ChangePasswordCardHeader.tsx new file mode 100644 index 0000000..172f70e --- /dev/null +++ b/app/merchant/mypage/change-password/components/ChangePasswordCardHeader.tsx @@ -0,0 +1,18 @@ +import { CardHeader, CardTitle } from "@/components/ui/card" +import { Lock } from "lucide-react" + +export default function ChangePasswordCardHeader() { + return ( + + +
+ + 비밀번호는 주기적으로 변경하는 것이 좋아요 +
+

+ 더 안전한 서비스 이용을 위해 지금 바꿔보세요 +

+
+
+ ) +} \ No newline at end of file diff --git a/app/merchant/mypage/change-password/components/ChangePasswordForm.tsx b/app/merchant/mypage/change-password/components/ChangePasswordForm.tsx new file mode 100644 index 0000000..cfa28bf --- /dev/null +++ b/app/merchant/mypage/change-password/components/ChangePasswordForm.tsx @@ -0,0 +1,111 @@ +"use client" + +import { useState } from "react" +import { toast } from "@/hooks/use-toast" +import { Card, CardContent } from "@/components/ui/card" +import Image from "next/image" +import PasswordInput from "./PasswordInput" +import { Button } from "@/components/ui/button" +import { motion } from "framer-motion" +import ChangePasswordCardHeader from "@/app/merchant/mypage/change-password/components/ChangePasswordCardHeader"; +import {updatePassword} from "@/app/merchant/mypage/change-password/api/password-update"; + +export default function ChangePasswordForm({ onSuccess }: { onSuccess: () => void }) { + const [formData, setFormData] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }) + const [errors, setErrors] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + if (errors[name as keyof typeof errors]) { + setErrors((prev) => ({ ...prev, [name]: "" })) + } + } + + const validateForm = () => { + let isValid = true + const newErrors = { ...errors } + + if (!formData.currentPassword) { + newErrors.currentPassword = "현재 비밀번호를 입력해주세요." + isValid = false + } + if (!formData.newPassword) { + newErrors.newPassword = "새 비밀번호를 입력해주세요." + isValid = false + } else if (formData.newPassword.length < 8) { + newErrors.newPassword = "비밀번호는 8자 이상이어야 합니다." + isValid = false + } else if (formData.newPassword === formData.currentPassword) { + newErrors.newPassword = "현재 비밀번호와 다른 비밀번호를 입력해주세요." + isValid = false + } + if (!formData.confirmPassword) { + newErrors.confirmPassword = "비밀번호 확인을 입력해주세요." + isValid = false + } else if (formData.confirmPassword !== formData.newPassword) { + newErrors.confirmPassword = "비밀번호가 일치하지 않습니다." + isValid = false + } + + setErrors(newErrors) + return isValid + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validateForm()) return; + + setIsSubmitting(true); + + try { + // 실제 API 호출 + await updatePassword(formData.currentPassword, formData.newPassword); + + toast({ + title: "비밀번호 변경 완료", + description: "비밀번호가 성공적으로 변경되었습니다.", + }); + + onSuccess(); + } catch (error) { + console.error(error); + toast({ + title: "오류 발생", + description: "비밀번호 변경 중 오류가 발생했습니다. 다시 시도해주세요.", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + }; + + + return ( + + + +
+ 보안 마스코트 +
+
+ + + + + +
+
+ ) +} diff --git a/app/merchant/mypage/change-password/components/ChangePasswordHeader.tsx b/app/merchant/mypage/change-password/components/ChangePasswordHeader.tsx new file mode 100644 index 0000000..cd9fe39 --- /dev/null +++ b/app/merchant/mypage/change-password/components/ChangePasswordHeader.tsx @@ -0,0 +1,28 @@ +"use client" + +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +export default function ChangePasswordHeader() { + const router = useRouter() + + return ( + + +

비밀번호 변경

+
+ ) +} \ No newline at end of file diff --git a/app/merchant/mypage/change-password/components/ChangePasswordSuccess.tsx b/app/merchant/mypage/change-password/components/ChangePasswordSuccess.tsx new file mode 100644 index 0000000..043a300 --- /dev/null +++ b/app/merchant/mypage/change-password/components/ChangePasswordSuccess.tsx @@ -0,0 +1,35 @@ +"use client" + +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { CheckCircle } from "lucide-react" +import { useRouter } from "next/navigation" + +export default function ChangePasswordSuccess() { + const router = useRouter() + + return ( + + + +
+ +
+

비밀번호 변경 완료!

+

비밀번호가 성공적으로 변경되었습니다.

+ +
+
+
+ ) +} diff --git a/app/merchant/mypage/change-password/components/PasswordInput.tsx b/app/merchant/mypage/change-password/components/PasswordInput.tsx new file mode 100644 index 0000000..a04c5cb --- /dev/null +++ b/app/merchant/mypage/change-password/components/PasswordInput.tsx @@ -0,0 +1,53 @@ +"use client" + +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Eye, EyeOff, AlertCircle } from "lucide-react" +import { useState } from "react" +import { motion } from "framer-motion" + +interface PasswordInputProps { + id: string + label: string + name: string + value: string + error?: string + placeholder?: string + onChange: (e: React.ChangeEvent) => void +} + +export default function PasswordInput({ id, label, name, value, error, placeholder, onChange }: PasswordInputProps) { + const [show, setShow] = useState(false) + + return ( + + +
+ + +
+ {error && ( +

+ + {error} +

+ )} +
+ ) +} diff --git a/app/merchant/mypage/change-password/page.tsx b/app/merchant/mypage/change-password/page.tsx new file mode 100644 index 0000000..6c2e6b6 --- /dev/null +++ b/app/merchant/mypage/change-password/page.tsx @@ -0,0 +1,36 @@ +"use client" + +import { useState } from "react" +import { motion } from "framer-motion" +import ChangePasswordHeader from "@/app/merchant/mypage/change-password/components/ChangePasswordHeader" +import ChangePasswordForm from "@/app/merchant/mypage/change-password/components/ChangePasswordForm" +import ChangePasswordSuccess from "@/app/merchant/mypage/change-password/components/ChangePasswordSuccess" + +export default function ChangePasswordPage() { + const [step, setStep] = useState<"form" | "success">("form") + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + }, + }, + } + + return ( +
+
+ + + {step === "form" ? ( + setStep("success")} /> + ) : ( + + )} + +
+
+ ) +} diff --git a/app/merchant/mypage/change-simple-password/api/change-simple-password.ts b/app/merchant/mypage/change-simple-password/api/change-simple-password.ts new file mode 100644 index 0000000..068126c --- /dev/null +++ b/app/merchant/mypage/change-simple-password/api/change-simple-password.ts @@ -0,0 +1,36 @@ +import { getApiUrl } from "@/lib/getApiUrl" +import { fetchWithAuth } from "@/lib/fetchWithAuth" + +const API_URL = getApiUrl() + +// 간편 비밀번호 유효성 검증 +export async function verifySimplePassword(simplePassword: string): Promise { + const res = await fetchWithAuth(`${API_URL}/api/merchants/simple-password/verify`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ simplePassword }), + credentials: "include", + }) + + if (!res.ok) { + throw new Error("간편 비밀번호가 올바르지 않습니다.") + } +} + +// 간편 비밀번호 수정 요청 +export async function updateSimplePassword(simplePassword: string): Promise { + const res = await fetchWithAuth(`${API_URL}/api/merchants/simple-password/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ simplePassword: simplePassword }), + credentials: "include", + }) + + if (!res.ok){ + throw new Error("간편 비밀번호 수정에 실패했습니다.") + } +} diff --git a/app/merchant/mypage/change-simple-password/components/ChangePinHeader.tsx b/app/merchant/mypage/change-simple-password/components/ChangePinHeader.tsx new file mode 100644 index 0000000..0a97feb --- /dev/null +++ b/app/merchant/mypage/change-simple-password/components/ChangePinHeader.tsx @@ -0,0 +1,21 @@ +"use client" + +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +export default function ChangePinHeader() { + const router = useRouter() + + return ( +
+
+ +

간편 비밀번호 변경

+
+
+
+ ) +} diff --git a/app/merchant/mypage/change-simple-password/components/ChangePinLayout.tsx b/app/merchant/mypage/change-simple-password/components/ChangePinLayout.tsx new file mode 100644 index 0000000..7f38812 --- /dev/null +++ b/app/merchant/mypage/change-simple-password/components/ChangePinLayout.tsx @@ -0,0 +1,21 @@ +"use client" + +import { ReactNode } from "react" +import ChangePinHeader from "./ChangePinHeader" + +interface Props { + children: ReactNode +} + +export default function ChangePinLayout({ children }: Props) { + return ( +
+ +
+
+ {children} +
+
+
+ ) +} diff --git a/app/merchant/mypage/change-simple-password/components/StepComplete.tsx b/app/merchant/mypage/change-simple-password/components/StepComplete.tsx new file mode 100644 index 0000000..9c6347b --- /dev/null +++ b/app/merchant/mypage/change-simple-password/components/StepComplete.tsx @@ -0,0 +1,29 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { motion } from "framer-motion" + +interface Props { + onDone: () => void +} + +export default function StepComplete({ onDone }: Props) { + return ( + + Security Mascot + +

비밀번호 변경 완료

+

간편 비밀번호가 성공적으로 변경되었습니다.

+ + +
+ ) +} \ No newline at end of file diff --git a/app/merchant/mypage/change-simple-password/components/StepConfirmPin.tsx b/app/merchant/mypage/change-simple-password/components/StepConfirmPin.tsx new file mode 100644 index 0000000..d76be6a --- /dev/null +++ b/app/merchant/mypage/change-simple-password/components/StepConfirmPin.tsx @@ -0,0 +1,20 @@ +"use client" + +import { Loader2 } from "lucide-react" +import VirtualKeypad from "@/components/virtual-keypad" + +interface Props { + onSubmit: (pin: string) => void + loading: boolean +} + +export default function StepConfirmPin({ onSubmit, loading }: Props) { + return loading ? ( +
+ +

비밀번호 변경 중...

+
+ ) : ( + + ) +} diff --git a/app/merchant/mypage/change-simple-password/components/StepIntro.tsx b/app/merchant/mypage/change-simple-password/components/StepIntro.tsx new file mode 100644 index 0000000..6c8093d --- /dev/null +++ b/app/merchant/mypage/change-simple-password/components/StepIntro.tsx @@ -0,0 +1,30 @@ +"use client" + +import Image from "next/image" +import { motion } from "framer-motion" + +interface Props { + title: string + subtitle: string +} + +export default function StepIntro({ title, subtitle }: Props) { + return ( + + Security Mascot +

{title}

+

{subtitle}

+
+ ) +} diff --git a/app/merchant/mypage/change-simple-password/components/StepNewPin.tsx b/app/merchant/mypage/change-simple-password/components/StepNewPin.tsx new file mode 100644 index 0000000..08fb779 --- /dev/null +++ b/app/merchant/mypage/change-simple-password/components/StepNewPin.tsx @@ -0,0 +1,11 @@ +"use client" + +import VirtualKeypad from "@/components/virtual-keypad" + +interface Props { + onSubmit: (pin: string) => void +} + +export default function StepNewPin({ onSubmit }: Props) { + return +} diff --git a/app/merchant/mypage/change-simple-password/components/StepVerifyCurrent.tsx b/app/merchant/mypage/change-simple-password/components/StepVerifyCurrent.tsx new file mode 100644 index 0000000..9ea5302 --- /dev/null +++ b/app/merchant/mypage/change-simple-password/components/StepVerifyCurrent.tsx @@ -0,0 +1,46 @@ +"use client" + +import { Alert, AlertDescription } from "@/components/ui/alert" +import { AlertCircle, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import VirtualKeypad from "@/components/virtual-keypad" + +interface Props { + onSubmit: (pin: string) => void + onForgot: () => void + loading: boolean + error?: string +} + +export default function StepVerifyCurrent({ onSubmit, onForgot, loading, error }: Props) { + return ( +
+ {error && ( + + + {error} + + )} + + {loading ? ( +
+ +

인증 중...

+
+ ) : ( + <> + +
+ +
+ + )} +
+ ) +} diff --git a/app/merchant/mypage/change-simple-password/page.tsx b/app/merchant/mypage/change-simple-password/page.tsx new file mode 100644 index 0000000..d73ff67 --- /dev/null +++ b/app/merchant/mypage/change-simple-password/page.tsx @@ -0,0 +1,167 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { toast } from "@/hooks/use-toast" +import ChangePinHeader from "./components/ChangePinHeader" +import StepIntro from "./components/StepIntro" +import StepVerifyCurrent from "./components/StepVerifyCurrent" +import StepNewPin from "./components/StepNewPin" +import StepConfirmPin from "./components/StepConfirmPin" +import StepComplete from "./components/StepComplete" +import { verifySimplePassword, updateSimplePassword } from "./api/change-simple-password" + +export default function ChangePinPage() { + const router = useRouter() + const [step, setStep] = useState<"verify-current" | "new-pin" | "confirm-pin" | "complete">("verify-current") + const [currentPin, setCurrentPin] = useState("") + const [newPin, setNewPin] = useState("") + const [confirmPin, setConfirmPin] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [attempts, setAttempts] = useState(0) + + const handleVerifyCurrent = async (pin: string) => { + setCurrentPin(pin) + setLoading(true) + setError("") + + try { + await verifySimplePassword(pin) + + setStep("new-pin") + toast({ + title: "인증 성공", + description: "새로운 간편 비밀번호를 입력해주세요.", + }) + } catch (err: any) { + const newAttempts = attempts + 1 + setAttempts(newAttempts) + + if (newAttempts >= 3) { + setError("비밀번호 입력 횟수를 초과했습니다. 비밀번호 찾기를 이용해주세요.") + } else { + setError(err?.message || `비밀번호가 일치하지 않습니다. (${newAttempts}/3)`) + } + } finally { + setLoading(false) + } + } + + const handleNewPin = (pin: string) => { + if (pin === currentPin) { + toast({ + title: "오류", + description: "현재 비밀번호와 다른 비밀번호를 입력해주세요.", + variant: "destructive", + }) + return + } + + setNewPin(pin) + toast({ + title: "새 비밀번호 입력 완료", + description: "비밀번호 확인을 위해 한번 더 입력해주세요.", + }) + setStep("confirm-pin") + } + + const handleConfirmPin = async (pin: string) => { + setConfirmPin(pin) + setLoading(true) + + try { + if (pin !== newPin) { + toast({ + title: "비밀번호 불일치", + description: "입력한 비밀번호가 일치하지 않습니다.", + variant: "destructive", + }) + setStep("new-pin") + return + } + + await updateSimplePassword(pin) + + setStep("complete") + toast({ + title: "비밀번호 변경 성공", + description: "간편 비밀번호가 성공적으로 변경되었습니다.", + }) + } catch (err: any) { + toast({ + title: "오류 발생", + description: err?.message || "비밀번호 변경 중 오류가 발생했습니다.", + variant: "destructive", + }) + setStep("new-pin") + } finally { + setLoading(false) + } + } + + const handleForgotPin = () => router.push("/merchant/mypage/reset-simple-password") + const handleComplete = () => router.push("/merchant/mypage") + + const stepContent = { + "verify-current": { + title: "현재 비밀번호 확인", + subtitle: "현재 사용 중인 6자리 비밀번호를 입력해주세요", + }, + "new-pin": { + title: "새 비밀번호 입력", + subtitle: "새로운 6자리 비밀번호를 입력해주세요", + }, + "confirm-pin": { + title: "비밀번호 확인", + subtitle: "새 비밀번호를 다시 한번 입력해주세요", + }, + } + + const pageVariants = { + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -10 }, + } + + return ( +
+ + +
+ {step !== "complete" && ( + + )} + +
+ + {step === "verify-current" && ( + + )} + {step === "new-pin" && } + {step === "confirm-pin" && ( + + )} + {step === "complete" && } + +
+
+
+ ) +} diff --git a/app/merchant/mypage/components/LogoutButton.tsx b/app/merchant/mypage/components/LogoutButton.tsx new file mode 100644 index 0000000..7e066f4 --- /dev/null +++ b/app/merchant/mypage/components/LogoutButton.tsx @@ -0,0 +1,75 @@ +"use client" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { LogOut } from "lucide-react" +import { useRouter } from "next/navigation" +import {useState} from "react"; +import {merchantLogout} from "@/app/merchant/mypage/api/merchant-logout"; + +export default function LogoutButton() { + const router = useRouter() + const [isLoading, setIsLoading] = useState(false); + + const handleLogout = async () => { + setIsLoading(true) + try { + merchantLogout() + router.push("/merchant/login") + } catch (error) { + console.error("로그아웃 실패", error) + alert("로그아웃 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + return ( +
+ + + + + + +
+
+ +
+ 로그아웃 하시겠습니까? + + 로그아웃 하시면 서비스 이용을 위해
다시 로그인해야 합니다. +
+
+
+ +
+ + + + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/merchant/mypage/components/MenuList.tsx b/app/merchant/mypage/components/MenuList.tsx new file mode 100644 index 0000000..cace500 --- /dev/null +++ b/app/merchant/mypage/components/MenuList.tsx @@ -0,0 +1,46 @@ +"use client" +import { motion } from "framer-motion" +import { ChevronRight } from "lucide-react" +import { useMenuItems } from "../data/menu-items" + +export default function MenuList() { + const menuItems = useMenuItems() + + return ( + +

+ + 전체 메뉴 +

+
+ {menuItems.map((item, i) => ( + + + + ))} +
+
+ ) +} diff --git a/app/merchant/mypage/components/MerchantMypageHeader.tsx b/app/merchant/mypage/components/MerchantMypageHeader.tsx new file mode 100644 index 0000000..af452c1 --- /dev/null +++ b/app/merchant/mypage/components/MerchantMypageHeader.tsx @@ -0,0 +1,17 @@ +"use client" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" + +export default function MerchantMypageHeader() { + const router = useRouter() + return ( +
+ +

마이페이지

+
{/* 빈 공간 */} +
+ ) +} diff --git a/app/merchant/mypage/components/ProfileCard.tsx b/app/merchant/mypage/components/ProfileCard.tsx new file mode 100644 index 0000000..6d7a79b --- /dev/null +++ b/app/merchant/mypage/components/ProfileCard.tsx @@ -0,0 +1,50 @@ +"use client" +import { motion } from "framer-motion" +import { ChevronRight } from "lucide-react" +import { useRouter } from "next/navigation" + +interface Props { + merchant: { + name: string; + storeName: string; + email: string; + phoneNumber: string; + businessNumber: string; + roadAddress: string; + category: string; + } +} + +export default function ProfileCard({ merchant }: Props) { + const router = useRouter() + + return ( + router.push("/merchant/mypage/info")} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + > +
+
+
+
+

{merchant.storeName}

+ 가맹점 +
+

{merchant.email}

+
+ +
+
+

대표자: {merchant.name}

+

사업자번호: {merchant.businessNumber}

+

{merchant.phoneNumber}

+
+
+
+ ) +} diff --git a/app/merchant/mypage/data/menu-items.ts b/app/merchant/mypage/data/menu-items.ts new file mode 100644 index 0000000..d56d6df --- /dev/null +++ b/app/merchant/mypage/data/menu-items.ts @@ -0,0 +1,52 @@ +import {CreditCard, Bell, FileText, Store, Lock, LockKeyholeOpen, ScanQrCodeIcon} from "lucide-react" +import { useRouter } from "next/navigation" +import {store} from "next/dist/build/output/store"; + +export const useMenuItems = () => { + const router = useRouter() + + return [ + { + title: "QR코드 확인하기", + icon: ScanQrCodeIcon, + action: () => router.push("/merchant/mypage/qr-code"), + color: "from-[#FFB020]/20 to-[#FFB020]/20", + iconColor: "text-[#FFB020]", + }, + { + title: "비밀번호 변경", + icon: LockKeyholeOpen, + action: () => router.push("/merchant/mypage/change-password"), + color: "from-[#10B981]/10 to-[#059669]/10", + iconColor: "text-[#10B981]", + }, + { + title: "간편 비밀번호 변경", + icon: Lock, + action: () => router.push("/merchant/mypage/change-simple-password"), + color: "from-[#F43F5E]/10 to-[#D1365A]/10", + iconColor: "text-[#F43F5E]", + }, + { + title: "결제 내역", + icon: CreditCard, + action: () => router.push("/merchant/wallet/totaltransaction"), + color: "from-[#8B5CF6]/10 to-[#7C3AED]/10", + iconColor: "text-[#8B5CF6]", + }, + { + title: "알림 설정", + icon: Bell, + action: () => router.push("/merchant/notifications/settings"), + color: "from-[#EC4899]/10 to-[#BE185D]/10", + iconColor: "text-[#EC4899]", + }, + { + title: "공지사항", + icon: FileText, + action: () => router.push("/merchant/notice"), + color: "from-[#3B82F6]/10 to-[#2563EB]/10", + iconColor: "text-[#3B82F6]", + }, + ] +} diff --git a/app/merchant/mypage/info/components/MerchantInfoCard.tsx b/app/merchant/mypage/info/components/MerchantInfoCard.tsx new file mode 100644 index 0000000..887febf --- /dev/null +++ b/app/merchant/mypage/info/components/MerchantInfoCard.tsx @@ -0,0 +1,92 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import {Building, User, Info, Mail, Phone, MapPin, List, Store} from "lucide-react" +import { motion } from "framer-motion" +import { Badge } from "@/components/ui/badge" + +const CATEGORY_LABELS: Record = { + FOOD: "음식점", + MEDICAL: "의료", + SERVICE: "서비스", + TOURISM: "관광", + LODGING: "숙박", + EDUCATION: "교육", +} + +interface Props { + merchant: { + storeName: string + name: string + businessNumber: string + email: string + phoneNumber: string + roadAddress: string + storeCategory: string + } +} + +const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + type: "spring", + stiffness: 100, + }, + }, +} + +export default function MerchantInfoCard({ merchant }: Props) { + + const InfoItem = ({ + icon: Icon, + label, + value, + badge, + }: { + icon: any + label: string + value?: string + badge?: string + }) => ( +
+
+ +
+
+

{label}

+

{value}

+ {badge && ( + + {badge} + + )} +
+
+ ) + + return ( + + + + + 안녕하세요 {merchant.storeName}님!⚡ + +

가맹점 정보를 확인하세요!

+
+ + + + + + + + + + + +
+ ) +} \ No newline at end of file diff --git a/app/merchant/mypage/info/components/MerchantInfoHeader.tsx b/app/merchant/mypage/info/components/MerchantInfoHeader.tsx new file mode 100644 index 0000000..0832ab2 --- /dev/null +++ b/app/merchant/mypage/info/components/MerchantInfoHeader.tsx @@ -0,0 +1,24 @@ +"use client" + +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { ArrowLeft } from "lucide-react" +import { motion } from "framer-motion" + +export default function MerchantInfoHeader() { + const router = useRouter() + + return ( + + +

가맹점 정보

+
+ ) +} diff --git a/app/merchant/mypage/info/page.tsx b/app/merchant/mypage/info/page.tsx new file mode 100644 index 0000000..f78b2ec --- /dev/null +++ b/app/merchant/mypage/info/page.tsx @@ -0,0 +1,70 @@ +"use client" + +import type React from "react" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import MerchantInfoHeader from "@/app/merchant/mypage/info/components/MerchantInfoHeader"; +import {getMerchantInfo} from "@/app/merchant/mypage/api/merchant-info"; +import MerchantInfoCard from "@/app/merchant/mypage/info/components/MerchantInfoCard"; + +export default function MerchantProfileEditPage() { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = useState(false) + const [merchant, setMerchant] = useState({ + storeName: "", + name: "", + email: "", + businessNumber: "", + phoneNumber: "", + roadAddress: "", + storeCategory: "", + }) + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + }, + }, + } + + const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + type: "spring", + stiffness: 100, + }, + }, + } + + useEffect(() => { + getMerchantInfo() + .then((data) => { + setMerchant(data) + }) + .catch((err) => { + console.error("가맹점주 정보 불러오기 실패:", err) + }) + }, []) + + return ( +
+
+ + + +
+ +
+
+
+
+ ) +} diff --git a/app/merchant/mypage/page.tsx b/app/merchant/mypage/page.tsx new file mode 100644 index 0000000..bd2b90d --- /dev/null +++ b/app/merchant/mypage/page.tsx @@ -0,0 +1,56 @@ +"use client" + +import {useEffect, useState} from "react" +import { useRouter } from "next/navigation" +import MerchantMypageHeader from "@/app/merchant/mypage/components/MerchantMypageHeader"; +import ProfileCard from "@/app/merchant/mypage/components/ProfileCard"; +import MenuList from "@/app/merchant/mypage/components/MenuList"; +import LogoutButton from "@/app/merchant/mypage/components/LogoutButton"; +import {getMerchantInfo} from "@/app/merchant/mypage/api/merchant-info"; + +export default function MerchantMyPage() { + const router = useRouter() + const [merchant, setMerchant] = useState({ + name: "", + storeName: "", + email: "", + phoneNumber: "", + businessNumber: "", + roadAddress: "", + category: "", + }) + + useEffect(() => { + getMerchantInfo() + .then((data) => { + setMerchant(data) + }) + .catch((err) => { + console.error("가맹점주 정보 불러오기 실패:", err) + }) + }, []) + + return ( +
+ {/* 헤더 */} +
+ {/* 로고 및 상단 네비게이션 */} + + + {/* 프로필 카드 */} + +
+ + {/* 메인 컨텐츠 */} +
+ {/* 전체 메뉴 */} + + + {/* 로그아웃 버튼 */} +
+ +
+
+
+ ) +} diff --git a/app/merchant/mypage/qr-code/components/MerchantInfoCard.tsx b/app/merchant/mypage/qr-code/components/MerchantInfoCard.tsx new file mode 100644 index 0000000..a633c35 --- /dev/null +++ b/app/merchant/mypage/qr-code/components/MerchantInfoCard.tsx @@ -0,0 +1,28 @@ +"use client" + +import { Store } from "lucide-react" +import { motion } from "framer-motion" + +interface Props { + name: string + address: string +} + +export default function MerchantInfoCard({ name, address }: Props) { + return ( + +
+ +
+

{name}

+

{address}

+
+
+
+ ) +} diff --git a/app/merchant/mypage/qr-code/components/PaymentGuideSection.tsx b/app/merchant/mypage/qr-code/components/PaymentGuideSection.tsx new file mode 100644 index 0000000..e47601f --- /dev/null +++ b/app/merchant/mypage/qr-code/components/PaymentGuideSection.tsx @@ -0,0 +1,44 @@ +"use client" + +import { motion } from "framer-motion" + +interface Props { + paymentCode: string +} + +export default function PaymentGuideSection({ paymentCode }: Props) { + return ( + +

💡 결제 방법 안내

+
+ + +
+
+ ) +} + +function GuideItem({ step, title, description }: { step: string; title: string; description: string }) { + return ( +
+
+
+ {step} +
+
+
{title}
+

{description}

+
+
+
+ ) +} diff --git a/app/merchant/mypage/qr-code/components/QRCodeSection.tsx b/app/merchant/mypage/qr-code/components/QRCodeSection.tsx new file mode 100644 index 0000000..3b3bfc2 --- /dev/null +++ b/app/merchant/mypage/qr-code/components/QRCodeSection.tsx @@ -0,0 +1,44 @@ +import { QRCodeSVG } from "qrcode.react" +import { QrCode } from "lucide-react" + +interface Props { + txId: string // 이게 QR 값이자, 결제 코드임 +} + +export default function QRCodeSection({ txId }: Props) { + return ( +
+
+
+ +

결제하기

+
+ + {/* QR 코드 */} +
+
+ +
+
+ + {/* 구분선 */} +
+
+ 또는 +
+
+ + {/* 결제 코드 */} +
+

QR코드 인식이 어려울 때

+
+
+ {txId} +
+
+

위 코드를 입력하세요

+
+
+
+ ) +} diff --git a/app/merchant/mypage/qr-code/components/QrHeader.tsx b/app/merchant/mypage/qr-code/components/QrHeader.tsx new file mode 100644 index 0000000..47b4020 --- /dev/null +++ b/app/merchant/mypage/qr-code/components/QrHeader.tsx @@ -0,0 +1,21 @@ +"use client" + +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +export default function QRPageHeader() { + const router = useRouter() + + return ( +
+
+ +

가맹점 QR코드

+
+
+
+ ) +} diff --git a/app/merchant/mypage/qr-code/data/payment.ts b/app/merchant/mypage/qr-code/data/payment.ts new file mode 100644 index 0000000..ea125b4 --- /dev/null +++ b/app/merchant/mypage/qr-code/data/payment.ts @@ -0,0 +1,39 @@ +export interface Voucher { + id: string; + title: string; + balance: number; + expiryDate: string; + icon?: React.ReactNode; + disabled?: boolean; +} + +export const myVouchers: Voucher[] = [ + { + id: "token", + title: "내 토큰", + balance: 50000, + expiryDate: "2025-12-31", + icon: "🪙", + }, + { + id: "v1", + title: "긴급 지원 바우처", + balance: 30000, + expiryDate: "2025-10-01", + icon: "🎟️", + }, + { + id: "v2", + title: "여행 바우처", + balance: 20000, + expiryDate: "2025-11-01", + icon: "🎟️", + }, + { + id: "v3", + title: "식사 바우처", + balance: 15000, + expiryDate: "2025-12-01", + icon: "🎟️", + }, +]; diff --git a/app/merchant/mypage/qr-code/data/storeqr.ts b/app/merchant/mypage/qr-code/data/storeqr.ts new file mode 100644 index 0000000..d0d29c8 --- /dev/null +++ b/app/merchant/mypage/qr-code/data/storeqr.ts @@ -0,0 +1,15 @@ +export interface StoreQRInfo { + storeId: string; + merchantId: string; + storeName: string; + address: string; +} + +export const mockStoreQR: Record = { + "1": { + storeId: "682025", + merchantId: "104", + storeName: "포토이즘", + address: "경기 양주시 옥정동로7다길 54", + } +}; diff --git a/app/merchant/mypage/qr-code/page.tsx b/app/merchant/mypage/qr-code/page.tsx new file mode 100644 index 0000000..ecb2ee1 --- /dev/null +++ b/app/merchant/mypage/qr-code/page.tsx @@ -0,0 +1,25 @@ +"use client" + +import QRPageHeader from "@/app/merchant/mypage/qr-code/components/QrHeader" +import MerchantInfoCard from "@/app/merchant/mypage/qr-code/components/MerchantInfoCard" +import QRCodeSection from "@/app/merchant/mypage/qr-code/components/QRCodeSection" +import PaymentGuideSection from "@/app/merchant/mypage/qr-code/components/PaymentGuideSection" +import { mockStoreQR } from "./data/storeqr" + +export default function MerchantQRCodePage() { + const storeId = "1" + const storeData = mockStoreQR[storeId] + + const txId = `m${storeData.merchantId}s${storeData.storeId}` + + return ( +
+ +
+ + + {/* 가짜 결제코드 표현 */} +
+
+ ) +} diff --git a/app/merchant/mypage/reset-simple-password/api/reset-simple-password.ts b/app/merchant/mypage/reset-simple-password/api/reset-simple-password.ts new file mode 100644 index 0000000..14c25e0 --- /dev/null +++ b/app/merchant/mypage/reset-simple-password/api/reset-simple-password.ts @@ -0,0 +1,15 @@ +import { getApiUrl } from "@/lib/getApiUrl" +import { fetchWithAuth } from "@/lib/fetchWithAuth" + +const API_URL = getApiUrl() + +// 임시 비밀번호 발송 요청 +export async function sendSimplePasswordResetEmail(email: string): Promise { + const res = await fetchWithAuth(`${API_URL}/api/merchants/find-simple-password?email=${encodeURIComponent(email)}`, { + method: "POST", + }) + + if (!res.ok) { + throw new Error("임시 비밀번호 발송에 실패했습니다.") + } +} diff --git a/app/merchant/mypage/reset-simple-password/components/ResetPinComplete.tsx b/app/merchant/mypage/reset-simple-password/components/ResetPinComplete.tsx new file mode 100644 index 0000000..62ffeb4 --- /dev/null +++ b/app/merchant/mypage/reset-simple-password/components/ResetPinComplete.tsx @@ -0,0 +1,36 @@ +"use client" + +import { CheckCircle } from "lucide-react" +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" + +interface Props { + email: string + onDone: () => void +} + +export default function ResetPinComplete({ email, onDone }: Props) { + return ( + + Security Mascot + +

이메일 발송 완료

+

+ {email}로 새로운 간편 비밀번호가 발송되었습니다. 이메일을 확인해주세요. +

+

+ 이메일이 도착하지 않았다면 스팸함을 확인하시거나 잠시 후 다시 시도해주세요. +

+ + +
+ ) +} diff --git a/app/merchant/mypage/reset-simple-password/components/ResetPinForm.tsx b/app/merchant/mypage/reset-simple-password/components/ResetPinForm.tsx new file mode 100644 index 0000000..e7c025c --- /dev/null +++ b/app/merchant/mypage/reset-simple-password/components/ResetPinForm.tsx @@ -0,0 +1,52 @@ +"use client" + +import { FormEvent } from "react" +import { Mail, Loader2 } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" + +interface Props { + email: string + setEmail: (email: string) => void + onSubmit: (e: FormEvent) => void + loading: boolean +} + +export default function ResetPinForm({ email, setEmail, onSubmit, loading }: Props) { + return ( +
+
+ + setEmail(e.target.value)} + placeholder="가입한 이메일을 입력하세요" + required + disabled={loading} + className="h-12 rounded-lg border-gray-300 focus:border-[#FFB020] focus:ring-[#FFB020]" + /> +
+ + +
+ ) +} diff --git a/app/merchant/mypage/reset-simple-password/components/ResetPinHeader.tsx b/app/merchant/mypage/reset-simple-password/components/ResetPinHeader.tsx new file mode 100644 index 0000000..cff7291 --- /dev/null +++ b/app/merchant/mypage/reset-simple-password/components/ResetPinHeader.tsx @@ -0,0 +1,21 @@ +"use client" + +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +export default function ResetPinHeader() { + const router = useRouter() + + return ( +
+
+ +

간편 비밀번호 찾기

+
+
+
+ ) +} diff --git a/app/merchant/mypage/reset-simple-password/page.tsx b/app/merchant/mypage/reset-simple-password/page.tsx new file mode 100644 index 0000000..36d6e62 --- /dev/null +++ b/app/merchant/mypage/reset-simple-password/page.tsx @@ -0,0 +1,109 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { toast } from "@/hooks/use-toast" +import Image from "next/image" +import { sendSimplePasswordResetEmail } from "./api/reset-simple-password" +import ResetPinHeader from "./components/ResetPinHeader" +import ResetPinForm from "./components/ResetPinForm" +import ResetPinComplete from "@/app/merchant/mypage/reset-simple-password/components/ResetPinComplete"; + +export default function ResetPinPage() { + const router = useRouter() + const [email, setEmail] = useState("") + const [loading, setLoading] = useState(false) + const [isComplete, setIsComplete] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!email || !email.includes("@")) { + toast({ + title: "이메일 오류", + description: "올바른 이메일 주소를 입력해주세요.", + variant: "destructive", + }) + return + } + + setLoading(true) + + try { + await sendSimplePasswordResetEmail(email) + + setIsComplete(true) + toast({ + title: "비밀번호 재설정 완료", + description: "새로운 간편 비밀번호가 이메일로 발송되었습니다.", + }) + } catch (error: any) { + toast({ + title: "전송 실패", + description: error?.message || "비밀번호 재설정 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setLoading(false) + } + } + + const handleComplete = () => { + router.push("/merchant/mypage") + } + + const pageVariants = { + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -10 }, + } + + return ( +
+ + +
+
+ {!isComplete ? ( + +
+ 비밀번호 찾기 +

간편 비밀번호 찾기

+

+ 가입하신 이메일을 입력하시면 새로운 간편 비밀번호를 이메일로 발송해 드립니다. +

+
+ + + +

+ * 이메일로 전송된 새 비밀번호로 로그인 후, 보안을 위해 비밀번호를 변경해주세요. +

+
+ ) : ( + + )} +
+
+
+ ) +} diff --git a/app/merchant/notice/[id]/page.tsx b/app/merchant/notice/[id]/page.tsx new file mode 100644 index 0000000..b1925b8 --- /dev/null +++ b/app/merchant/notice/[id]/page.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useParams, useSearchParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Calendar } from "lucide-react"; + +import SkeletonDetail from "@/app/merchant/notice/components/SkeletonDetail"; +import Header from "@/components/common/Header"; +import { fetchNoticeDetail, NoticeDetail } from "@/app/merchant/notice/api/notice-api"; + +export default function NoticeDetailPage() { + const { id } = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const noticeId = Array.isArray(id) ? id[0] : id; + const [notice, setNotice] = useState(null); + const [loading, setLoading] = useState(true); + + const page = searchParams.get("page") ?? "1"; + + useEffect(() => { + if (!noticeId) return; + + setLoading(true); + fetchNoticeDetail(noticeId) + .then(setNotice) + .catch((error) => { + console.error("Error fetching notice:", error); + }) + .finally(() => { + setLoading(false); + }); + }, [noticeId]); + + useEffect(() => { + const handlePopState = (event: PopStateEvent) => { + event.preventDefault(); + router.replace(`/merchant/notice?page=${page}`); + }; + + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, [page]); + + return ( +
+
+
+
+
+ {loading ? ( + + ) : notice ? ( + <> +
+
+ + {notice.createdAt?.slice(0, 10)} +
+
+ +

{notice.title}

+ +
+ {notice.content} +
+ + ) : ( +
+ 공지사항을 찾을 수 없습니다 +
+ )} +
+
+ ); +} diff --git a/app/merchant/notice/api/notice-api.ts b/app/merchant/notice/api/notice-api.ts new file mode 100644 index 0000000..93ddde1 --- /dev/null +++ b/app/merchant/notice/api/notice-api.ts @@ -0,0 +1,58 @@ +import axios from "axios"; +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export interface Notice { + id: number; + title: string; + content: string; + createdAt: string; +} + +export interface NoticeDetail { + id: number; + title: string; + content: string; + createdAt: string; +} + +export interface FetchNoticeListResult { + content: Notice[]; + totalPages: number; +} + +export async function fetchNoticeList(currentPage: number): Promise { + const response = await fetchWithAuth(`${API_URL}/api/merchants/notice?page=${currentPage}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + + if (!response.ok) { + throw new Error("공지사항 목록을 불러오는 데 실패했습니다."); + } + + const data = await response.json(); + const result = typeof data.result === "string" ? JSON.parse(data.result) : data.result; + + return { + content: result.content || [], + totalPages: result.totalPages || 1, + }; +} + +export async function fetchNoticeDetail(id: string): Promise { + const response = await fetchWithAuth(`${API_URL}/api/merchants/notice/${id}`, { + method: "GET", + credentials: "include", + }); + + const data = await response.json(); + const result = typeof data.result === "string" ? JSON.parse(data.result) : data.result; + + return result; +} \ No newline at end of file diff --git a/app/merchant/notice/components/NoticeItem.tsx b/app/merchant/notice/components/NoticeItem.tsx new file mode 100644 index 0000000..01ef4f8 --- /dev/null +++ b/app/merchant/notice/components/NoticeItem.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { ChevronRight } from "lucide-react"; + +export default function NoticeItem({ + notice, + isNew = false, + currentPage, +}: { + notice: any; + isNew?: boolean; + currentPage: number; +}) { + const router = useRouter(); + + const handleClick = () => { + router.push(`/merchant/notice/${notice.id}?page=${currentPage + 1}`); + }; + + return ( +
+
+
+

{notice.title}

+ {isNew && ( + + NEW + + )} +
+
+ {notice.createdAt?.slice(0, 10)} +
+
+ +
+ ); +} diff --git a/app/merchant/notice/components/NoticeList.tsx b/app/merchant/notice/components/NoticeList.tsx new file mode 100644 index 0000000..5dce717 --- /dev/null +++ b/app/merchant/notice/components/NoticeList.tsx @@ -0,0 +1,35 @@ +"use client"; + +import NoticeItem from "@/app/merchant/notice/components/NoticeItem"; + +interface Notice { + id: number; + title: string; + content: string; + createdAt: string; +} + +export default function NoticeList({ + notices, + latestNoticeIds = [], + currentPage, +}: { + notices: Notice[]; + latestNoticeIds?: number[]; + currentPage: number; +}) { + return ( +
+
+ {notices.map((notice) => ( + + ))} +
+
+ ) +} diff --git a/app/merchant/notice/components/SkeletonDetail.tsx b/app/merchant/notice/components/SkeletonDetail.tsx new file mode 100644 index 0000000..49c3beb --- /dev/null +++ b/app/merchant/notice/components/SkeletonDetail.tsx @@ -0,0 +1,21 @@ +export default function SkeletonDetail() { + return ( +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/app/merchant/notice/components/SkeletonList.tsx b/app/merchant/notice/components/SkeletonList.tsx new file mode 100644 index 0000000..4828a96 --- /dev/null +++ b/app/merchant/notice/components/SkeletonList.tsx @@ -0,0 +1,23 @@ +import Header from "@/components/common/Header" + +export function SkeletonList() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 7 }).map((_, index) => ( +
+ ))} +
+
+
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+
+ ) +} diff --git a/app/merchant/notice/page.tsx b/app/merchant/notice/page.tsx new file mode 100644 index 0000000..64815bd --- /dev/null +++ b/app/merchant/notice/page.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useEffect, useState, Suspense } from "react" +import { useSearchParams, useRouter } from "next/navigation" + +import NoticeList from "@/app/merchant/notice/components/NoticeList"; +import Pagination from "@/components/common/Pagination"; +import { SkeletonList } from "@/app/merchant/notice/components/SkeletonList"; +import Header from "@/components/common/Header"; + +import { fetchNoticeList, type Notice } from "@/app/merchant/notice/api/notice-api" +import { filterLatestNoticeIds } from "@/lib/filterLatestNotices" + +function NoticesContent() { + const router = useRouter() + const searchParams = useSearchParams() + const currentPage = Number.parseInt(searchParams.get("page") ?? "1", 10) - 1 + + const [notices, setNotices] = useState([]) + const [totalPages, setTotalPages] = useState(1) + const [loading, setLoading] = useState(true) + const [latestNoticeIds, setLatestNoticeIds] = useState([]) + + useEffect(() => { + async function fetchData() { + setLoading(true); + try { + const { content, totalPages } = await fetchNoticeList(currentPage); + setNotices(content); + setTotalPages(totalPages); + + if (currentPage === 0) { + const latestIds = filterLatestNoticeIds(content); + setLatestNoticeIds(latestIds); + } + } catch (error) { + console.error("공지사항 불러오기 실패:", error); + } finally { + setLoading(false); + } + } + + fetchData(); + }, [currentPage]); + + if (loading) { + return ; + } + + return ( +
+
+
+
+
+ +
+ +
+ +
+ router.push(`/merchant/notice?page=${page}`)} + /> +
+
+
+ ) +} + +export default function NoticesPage() { + return ( + Loading...
}> + + + ) +} diff --git a/app/merchant/page.tsx b/app/merchant/page.tsx new file mode 100644 index 0000000..bead1e6 --- /dev/null +++ b/app/merchant/page.tsx @@ -0,0 +1,93 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { AuthButtons } from "@/components/auth-buttons" +import { MascotImage } from "@/app/merchant/components/MascotImage" +import { motion } from "framer-motion" + +export default function MerchantPage() { + const router = useRouter() + const [isAnimating, setIsAnimating] = useState(false) + + const handleLogin = () => { + setIsAnimating(true) + setTimeout(() => { + router.push("/merchant/login") + }, 300) + } + + const handleSignup = () => { + setIsAnimating(true) + setTimeout(() => { + router.push("/merchant/signup") + }, 300) + } + + const handleSwitchToUser = () => { + router.push("/") // 일반 사용자 랜딩 페이지 경로로 수정 + } + + return ( +
+
+ {/* 마스코트 */} +
+ +
+ + {/* 서비스명 & 설명 */} + + Tokkit + + + + 중앙은행 디지털화폐(CBDC)로
+ 스마트하고 안전한 금융 생활을 시작하세요 +
+ + {/* 로그인 / 회원가입 버튼 */} +
+ + +
+ + {/* 일반 사용자 전환 */} +
+ +
+
+
+ ) +} diff --git a/app/merchant/reset-password/api/reset-password-auth.ts b/app/merchant/reset-password/api/reset-password-auth.ts new file mode 100644 index 0000000..3e10e8e --- /dev/null +++ b/app/merchant/reset-password/api/reset-password-auth.ts @@ -0,0 +1,35 @@ +import axios from "axios"; +import { getApiUrl } from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export const requestTempPassword = async (businessNumber: string) => { + try { + const response = await axios.post(`${API_URL}/api/merchants/findPw`, null, { + params: { businessNumber }, + }); + + const data = response.data; + + if (!data.isSuccess) { + const error = new Error(data.message); + (error as any).code = data.code; + throw error; + } + + return data; + } catch (err: any) { + // axios 자체 에러 처리도 포함 + const response = err?.response; + + if (response?.data && !response.data.isSuccess) { + const error = new Error(response.data.message); + (error as any).code = response.data.code; + throw error; + } + + // 네트워크 오류 등 + throw new Error("네트워크 오류 또는 알 수 없는 오류가 발생했습니다."); + } +}; + diff --git a/app/merchant/reset-password/components/ResetPasswordComplete.tsx b/app/merchant/reset-password/components/ResetPasswordComplete.tsx new file mode 100644 index 0000000..3448dc5 --- /dev/null +++ b/app/merchant/reset-password/components/ResetPasswordComplete.tsx @@ -0,0 +1,36 @@ +"use client" + +import { Button } from "@/components/ui/button" + +interface ResetPasswordCompleteProps { + email: string + onComplete: () => void +} + +export default function ResetPasswordComplete({ email, onComplete }: ResetPasswordCompleteProps) { + return ( +
+ Security Mascot + +

비밀번호 초기화 완료

+

+ 임시 비밀번호가 {email}
발송되었습니다 📮 +
+ 메일함을 확인해 주세요. +

+ +
+

+ 임시 비밀번호로 로그인 후
보안을 위해 비밀번호를 변경해주세요. +

+
+ + +
+ ) +} diff --git a/app/merchant/reset-password/components/ResetPasswordForm.tsx b/app/merchant/reset-password/components/ResetPasswordForm.tsx new file mode 100644 index 0000000..2d776a4 --- /dev/null +++ b/app/merchant/reset-password/components/ResetPasswordForm.tsx @@ -0,0 +1,73 @@ +"use client" + +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Loader2 } from "lucide-react" +import Image from "next/image" +import type React from "react" + +interface ResetPasswordFormProps { + businessId: string + setBusinessId: (value: string) => void + handleSubmit: (e: React.FormEvent) => void + loading: boolean +} + +export default function ResetPasswordForm({ + businessId, + setBusinessId, + handleSubmit, + loading, + }: ResetPasswordFormProps) { + return ( + <> +
+ 가맹점 로고 +
+ +

비밀번호 찾기

+

+ 가입하신 사업자 등록번호를 입력하시면 등록된 이메일로 임시 비밀번호를 발급해 드립니다. +

+ +
+
+ + setBusinessId(e.target.value)} + placeholder="사업자 등록번호를 입력하세요" + required + disabled={loading} + className="h-12 rounded-xl border-[#E0E0E0] dark:border-[#333333] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + /> +
+ + +
+ + ) +} diff --git a/app/merchant/reset-password/page.tsx b/app/merchant/reset-password/page.tsx new file mode 100644 index 0000000..8412d1d --- /dev/null +++ b/app/merchant/reset-password/page.tsx @@ -0,0 +1,127 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { CheckCircle, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { toast } from "@/hooks/use-toast" +import Header from "@/components/common/Header" +import Image from "next/image" +import ResetPasswordForm from "@/app/merchant/reset-password/components/ResetPasswordForm"; +import ResetPasswordComplete from "@/app/merchant/reset-password/components/ResetPasswordComplete"; +import {requestTempPassword} from "@/app/merchant/reset-password/api/reset-password-auth"; + +export default function MerchantResetPasswordPage() { + const router = useRouter() + const [step, setStep] = useState<"business" | "complete">("business") + const [businessNumber, setBusinessNumber] = useState("") + const [email, setEmail] = useState("") // 백엔드에서 찾은 이메일을 저장 (실제로는 마스킹된 이메일이 표시됨) + const [loading, setLoading] = useState(false) + + // 임시 비밀번호 발급 처리 + const handleResetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!businessNumber || businessNumber.length < 10) { + toast({ + title: "사업자 등록번호 오류", + description: "올바른 사업자 등록번호를 입력해주세요.", + variant: "destructive", + }); + return; + } + + setLoading(true); + + try { + const result = await requestTempPassword(businessNumber); // 백엔드 응답에서 email 추출 + const email = result.result.email; + + console.log(email); + + // 이메일 마스킹 처리 + const maskedEmail = email.replace(/(\w{3})[\w.-]+@([\w.]+)/, "$1***@$2"); + setEmail(maskedEmail); + + console.log(maskedEmail); + + // 완료 단계로 이동 + setStep("complete"); + + toast({ + title: "임시 비밀번호 발급 완료", + description: `${maskedEmail}로 임시 비밀번호가 발송되었습니다.`, + }); + } catch (error: any) { + toast({ + title: "오류 발생", + description: error.message || "임시 비밀번호 발급 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + // 완료 후 로그인 페이지로 이동 + const handleComplete = () => { + router.push("/merchant/login") + } + + // 페이지 진입 애니메이션 + const pageVariants = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + } + + return ( +
+ {/* 헤더 */} +
+ + {/* 컨텐츠 */} +
+
+ {step === "business" && ( + + + + )} + + {step === "complete" && ( + + + + )} +
+
+
+ ) +} diff --git a/app/merchant/signup/business/api/business-ocr.ts b/app/merchant/signup/business/api/business-ocr.ts new file mode 100644 index 0000000..9e4910d --- /dev/null +++ b/app/merchant/signup/business/api/business-ocr.ts @@ -0,0 +1,34 @@ +import { getApiUrl } from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export interface BusinessOcrResult { + businessNumber: string + storeName: string + representativeName: string + roadAddress: string +} + +export async function requestBusinessOcr(imageFile: File): Promise { + const formData = new FormData() + formData.append("image", imageFile) + + const res = await fetch(`${API_URL}/api/ocr/business`, { + method: "POST", + body: formData, + }) + + if (!res.ok) { + const error = await res.json().catch(() => ({})) + const message = error?.message || "사업자등록증 인식에 실패했습니다." + throw new Error(message) + } + + const json = await res.json(); + return { + businessNumber: json.result.businessNumber, + storeName: json.result.storeName, + representativeName: json.result.representativeName, + roadAddress: json.result.roadAddress, + } +} diff --git a/app/merchant/signup/business/api/region.ts b/app/merchant/signup/business/api/region.ts new file mode 100644 index 0000000..9d339b5 --- /dev/null +++ b/app/merchant/signup/business/api/region.ts @@ -0,0 +1,33 @@ +import { getApiUrl } from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export interface SidoResponse { + sidoName: string +} + +export interface SigunguResponse { + sigunguName: string +} + +export async function fetchSidoList(): Promise { + const res = await fetch(`${API_URL}/api/regions/sido`) + + if (!res.ok) { + throw new Error("시/도 목록을 불러오지 못했습니다.") + } + + const data = await res.json() + return data.result.map((item: SidoResponse) => item.sidoName) +} + +export async function fetchSigunguList(sido: string): Promise { + const res = await fetch(`${API_URL}/api/regions/sigungu?sido=${encodeURIComponent(sido)}`) + + if (!res.ok) { + throw new Error("시/군/구 목록을 불러오지 못했습니다.") + } + + const data = await res.json() + return data.result.map((item: SigunguResponse) => item.sigunguName) +} diff --git a/app/merchant/signup/business/components/AddressSearchModal.tsx b/app/merchant/signup/business/components/AddressSearchModal.tsx new file mode 100644 index 0000000..f524adc --- /dev/null +++ b/app/merchant/signup/business/components/AddressSearchModal.tsx @@ -0,0 +1,139 @@ +// AddressSearchModal.tsx +"use client" + +import { useState, useEffect } from "react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Building, RefreshCw, Search, X } from "lucide-react" + +interface AddressSearchModalProps { + onClose: () => void + onSelect: (data: { + businessAddress: string + latitude: string + longitude: string + zipcode: string + sido: string + sigungu: string + }) => void +} + +export default function AddressSearchModal({ onClose, onSelect }: AddressSearchModalProps) { + const [scriptLoaded, setScriptLoaded] = useState(false) + const [searchingAddress, setSearchingAddress] = useState(false) + const [addressKeyword, setAddressKeyword] = useState("") + const [addressResults, setAddressResults] = useState([]) + + useEffect(() => { + if (typeof window === "undefined") return + + const script = document.createElement("script") + script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_API_KEY}&libraries=services&autoload=false` + script.async = true + script.onload = () => { + const kakao = (window as any).kakao + kakao.maps.load(() => setScriptLoaded(true)) + } + document.head.appendChild(script) + return () => { + document.head.removeChild(script) + } + }, []) + + const searchAddress = () => { + if (!addressKeyword.trim() || !scriptLoaded) return + + setSearchingAddress(true) + setAddressResults([]) + + const kakao = (window as any).kakao + const geocoder = new kakao.maps.services.Geocoder() + const places = new kakao.maps.services.Places() + + places.keywordSearch(addressKeyword, (result: any, status: any) => { + if (status === kakao.maps.services.Status.OK) { + const filtered = result.filter((r: any) => r.address_name) + setAddressResults(filtered) + } + + geocoder.addressSearch(addressKeyword, (geoResult: any, geoStatus: any) => { + setSearchingAddress(false) + if (geoStatus === kakao.maps.services.Status.OK) { + setAddressResults((prev) => { + const combined = [...prev] + geoResult.forEach((item: any) => { + if (!prev.some((p) => p.address_name === item.address_name)) { + combined.push({ + ...item, + place_name: item.building_name || item.address_name, + address_name: item.address_name, + road_address_name: item.road_address?.address_name || item.address_name, + }) + } + }) + return combined + }) + } + }) + }) + } + + const handleSelect = (item: any) => { + const address = item.road_address_name || item.address_name + const lat = item.y + const lng = item.x + const zip = item.road_address?.zone_no || item.address?.b_code || "" + const sido = item.road_address?.region_1depth_name || item.address?.region_1depth_name || "" + const sigungu = item.road_address?.region_2depth_name || item.address?.region_2depth_name || "" + + onSelect({ businessAddress: address, latitude: lat, longitude: lng, zipcode: zip, sido, sigungu }) + onClose() + } + + return ( +
+
+ +

주소 검색

+
+
+
+
+ setAddressKeyword(e.target.value)} + placeholder="도로명, 지번, 건물명으로 검색" + onKeyDown={(e) => e.key === "Enter" && searchAddress()} + /> + +
+
+ {addressResults.length > 0 && ( +
+
    + {addressResults.map((item, index) => ( +
  • handleSelect(item)}> +
    + +
    +

    {item.place_name !== item.address_name ? item.place_name : "주소"}

    +

    {item.road_address_name || item.address_name}

    + {item.road_address_name && item.road_address_name !== item.address_name && ( +

    {item.address_name}

    + )} +
    +
    +
  • + ))} +
+
+ )} +
+
+ ) +} diff --git a/app/merchant/signup/business/components/BusinessHeader.tsx b/app/merchant/signup/business/components/BusinessHeader.tsx new file mode 100644 index 0000000..cb08dee --- /dev/null +++ b/app/merchant/signup/business/components/BusinessHeader.tsx @@ -0,0 +1,56 @@ +"use client" + +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Store } from "lucide-react" +import { motion } from "framer-motion" +import Image from "next/image" + +interface BusinessHeaderProps { + onBack: () => void +} + +export default function BusinessHeader({ onBack }: BusinessHeaderProps) { + return ( + <> +
+ + + + 사업자 정보 입력 + +
+ + + 가맹점 마스코트 + + + ) +} diff --git a/app/merchant/signup/business/components/CaptureStep.tsx b/app/merchant/signup/business/components/CaptureStep.tsx new file mode 100644 index 0000000..62da1a7 --- /dev/null +++ b/app/merchant/signup/business/components/CaptureStep.tsx @@ -0,0 +1,214 @@ +"use client" + +import { + Camera, + Upload, + AlertCircle, + RefreshCw +} from "lucide-react" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Button } from "@/components/ui/button" +import Image from "next/image" +import { useEffect, useRef, useState } from "react" +import {requestBusinessOcr} from "@/app/merchant/signup/business/api/business-ocr"; + +interface CaptureStepProps { + onNext: () => void +} + +export default function CaptureStep({ onNext }: CaptureStepProps) { + const videoRef = useRef(null) + const canvasRef = useRef(null) + const fileInputRef = useRef(null) + + const [activeTab, setActiveTab] = useState("camera") + const [cameraError, setCameraError] = useState(null) + const [processingOcr, setProcessingOcr] = useState(false) + + useEffect(() => { + if (activeTab === "camera") { + initCamera() + } else { + stopCamera() + } + + return () => stopCamera() + }, [activeTab]) + + const initCamera = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + }) + if (videoRef.current) { + videoRef.current.srcObject = stream + } + setCameraError(null) + } catch (err) { + console.error("카메라 접근 오류:", err) + setCameraError("카메라에 접근할 수 없습니다. 권한을 확인해주세요.") + setActiveTab("upload") + } + } + + const stopCamera = () => { + if (videoRef.current?.srcObject) { + const stream = videoRef.current.srcObject as MediaStream + stream.getTracks().forEach((track) => track.stop()) + videoRef.current.srcObject = null + } + } + + const captureImage = () => { + if (videoRef.current && canvasRef.current) { + const canvas = canvasRef.current + const ctx = canvas.getContext("2d") + if (!ctx) return + + canvas.width = videoRef.current.videoWidth + canvas.height = videoRef.current.videoHeight + ctx.drawImage(videoRef.current, 0, 0, canvas.width, canvas.height) + + canvas.toBlob((blob) => { + if (blob) { + const file = new File([blob], "business.jpg", { type: "image/jpeg" }) + processOCR(file) + } + }, "image/jpeg", 0.95) + } + } + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + if (!file.type.match("image/jpeg|image/png|application/pdf")) { + alert("JPG, PNG, PDF 파일만 업로드 가능합니다.") + return + } + + if (file.size > 5 * 1024 * 1024) { + alert("파일 크기는 5MB를 초과할 수 없습니다.") + return + } + + processOCR(file) + } + + const processOCR = async (file: File) => { + setProcessingOcr(true) + + try { + const result = await requestBusinessOcr(file) + + // OCR 결과를 sessionStorage에 저장 + sessionStorage.setItem("businessNumber", result.businessNumber) + sessionStorage.setItem("storeName", result.storeName) + sessionStorage.setItem("name", result.representativeName) + sessionStorage.setItem("roadAddress", result.roadAddress) + + // 다음 단계로 전환 + onNext() + } catch (err: any) { + alert(err.message || "OCR 처리 중 오류가 발생했습니다.") + } finally { + setProcessingOcr(false) + } + } + + + return ( +
+

+ 사업자등록증 촬영 +

+

+ 사업자등록증을 촬영하거나 이미지를 업로드해주세요 +

+ + + + 카메라 촬영 + 이미지 업로드 + + + +
+ {cameraError ? ( +
+ +

{cameraError}

+ +
+ ) : ( + <> +
+ + +
+ + +
+ + +
fileInputRef.current?.click()} + className="border-2 border-dashed border-[#E0E0E0] rounded-xl p-4 flex flex-col items-center justify-center cursor-pointer hover:border-[#FFB020] transition-colors h-[320px] mb-4" + > + +

+ 사업자등록증 업로드 +

+

+ JPG, PNG, PDF 파일 (최대 5MB) +

+
+ + +
+
+
+ + {processingOcr && ( +
+ +

사업자등록증 정보를 인식하고 있습니다...

+
+ )} +
+ ) +} diff --git a/app/merchant/signup/business/components/InfoStep.tsx b/app/merchant/signup/business/components/InfoStep.tsx new file mode 100644 index 0000000..1ef2e5e --- /dev/null +++ b/app/merchant/signup/business/components/InfoStep.tsx @@ -0,0 +1,264 @@ +"use client" + +import { useEffect, useState } from "react" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Search } from "lucide-react" +import { useRouter } from "next/navigation" +import { fetchSidoList, fetchSigunguList } from "@/app/merchant/signup/business/api/region" +import AddressSearchModal from "./AddressSearchModal" + +interface InfoStepProps { + onAddressSearch: () => void; +} + +const STORE_CATEGORIES = [ + { value: "음식점", label: "음식점" }, + { value: "의료", label: "의료" }, + { value: "서비스", label: "서비스" }, + { value: "관광", label: "관광" }, + { value: "숙박", label: "숙박" }, + { value: "교육", label: "교육" }, +] + +export default function InfoStep({onAddressSearch}: InfoStepProps) { + const router = useRouter() + const [businessNumber, setBusinessNumber] = useState("") + const [storeName, setStoreName] = useState("") + const [name, setName] = useState("") + const [roadAddress, setRoadAddress] = useState("") + const [detailAddress, setDetailAddress] = useState("") + const [selectedSido, setSelectedSido] = useState("") + const [selectedSigungu, setSelectedSigungu] = useState("") + const [selectedCategory, setSelectedCategory] = useState("") + const [sidoList, setSidoList] = useState([]) + const [sigunguList, setSigunguList] = useState([]) + const [loading, setLoading] = useState(false) + const [isModalOpen, setIsModalOpen] = useState(false) + + // 주소 선택 콜백 + const handleAddressSelected = (data: { + businessAddress: string + sido: string + sigungu: string + latitude: string + longitude: string + zipcode: string + }) => { + sessionStorage.setItem("businessAddressData", JSON.stringify(data)) + setRoadAddress(data.businessAddress) + setSelectedSido(data.sido) + setSelectedSigungu(data.sigungu) + } + + // 초기화 시 OCR 세션 반영 + useEffect(() => { + const savedBusinessNumber = sessionStorage.getItem("businessNumber") || "" + const savedStoreName = sessionStorage.getItem("storeName") || "" + const savedName = sessionStorage.getItem("name") || "" + const savedRoadAddress = sessionStorage.getItem("roadAddress") || "" + + setBusinessNumber(savedBusinessNumber) + setStoreName(savedStoreName) + setName(savedName) + setRoadAddress(savedRoadAddress) + + const savedAddressJson = sessionStorage.getItem("businessAddressData") + if (savedAddressJson) { + try { + const parsed = JSON.parse(savedAddressJson) + if (parsed.sido) setSelectedSido(parsed.sido) + if (parsed.sigungu) setSelectedSigungu(parsed.sigungu) + } catch (e) { + console.error("주소 파싱 실패:", e) + } + } + }, []) + + useEffect(() => { + const loadSido = async () => { + try { + const list = await fetchSidoList() + setSidoList(list) + } catch (e) { + console.error("시/도 불러오기 실패:", e) + } + } + + loadSido() + }, []) + + useEffect(() => { + if (selectedSido) { + const loadSigungu = async () => { + try { + const list = await fetchSigunguList(selectedSido) + setSigunguList(list) + } catch (e) { + console.error("시/군/구 불러오기 실패:", e) + } + } + + loadSigungu() + } else { + setSigunguList([]) + } + }, [selectedSido]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!businessNumber || !storeName || !name || !roadAddress || !selectedSido || !selectedSigungu || !selectedCategory) { + alert("모든 필수 정보를 입력해주세요.") + return + } + + const payload = { + businessNumber, + storeName, + name, + roadAddress, + detailAddress, + sido: selectedSido, + sigungu: selectedSigungu, + category: selectedCategory, + } + + sessionStorage.setItem("businessInfo", JSON.stringify(payload)) + setLoading(true) + await new Promise((res) => setTimeout(res, 1000)) + setLoading(false) + + router.push("/merchant/signup/wallet") + } + + return ( + <> +
+
+ + setBusinessNumber(e.target.value.replace(/\D/g, ""))} + placeholder="0000000000" + maxLength={10} + required + /> +
+ +
+ + setStoreName(e.target.value)} placeholder="상호명" required /> +
+ +
+ + setName(e.target.value)} placeholder="대표자명" required /> +
+ +
+ +
+ setIsModalOpen(true)} + placeholder="주소 검색 버튼을 클릭하세요" + className="pr-10" + /> + +
+ setDetailAddress(e.target.value)} + placeholder="상세 주소 입력 (선택)" + className="text-sm mt-2" + /> +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+ + {isModalOpen && ( + setIsModalOpen(false)} + onSelect={handleAddressSelected} + /> + )} + + ) +} diff --git a/app/merchant/signup/business/page.tsx b/app/merchant/signup/business/page.tsx new file mode 100644 index 0000000..be1f903 --- /dev/null +++ b/app/merchant/signup/business/page.tsx @@ -0,0 +1,23 @@ +"use client" + +import { useState } from "react" +import BusinessHeader from "./components/BusinessHeader" +import CaptureStep from "./components/CaptureStep" +import InfoStep from "./components/InfoStep" + +export default function BusinessSignupPage() { + const [formStep, setFormStep] = useState<"capture" | "info">("capture") + + return ( +
+ window.history.back()} /> +
+ {formStep === "capture" ? ( + setFormStep("info")} /> + ) : ( + {}} /> + )} +
+
+ ) +} diff --git a/app/merchant/signup/components/MerchantTermsHeader.tsx b/app/merchant/signup/components/MerchantTermsHeader.tsx new file mode 100644 index 0000000..e4484ed --- /dev/null +++ b/app/merchant/signup/components/MerchantTermsHeader.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" +import { ArrowLeft } from "lucide-react" + +export default function MerchantTermsHeader() { + const router = useRouter() + + return ( +
+ + + 약관 동의 + +
+ ) +} diff --git a/app/merchant/signup/components/TermsAgreementCard.tsx b/app/merchant/signup/components/TermsAgreementCard.tsx new file mode 100644 index 0000000..0a9e5d8 --- /dev/null +++ b/app/merchant/signup/components/TermsAgreementCard.tsx @@ -0,0 +1,127 @@ +"use client" + +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" +import { Eye, Check } from "lucide-react" +import { Term } from "../data/merchant-terms" + +interface Props { + terms: Term[] + agreedTerms: string[] + viewedTerms: string[] + allAgreed: boolean + termRefs: React.MutableRefObject> + onToggleAll: () => void + onToggleTerm: (termId: string) => void + onViewTerm: (termId: string) => void + onSubmit: () => void +} + +export default function TermsAgreementCard({ + terms, + agreedTerms, + viewedTerms, + allAgreed, + termRefs, + onToggleAll, + onToggleTerm, + onViewTerm, + onSubmit, + }: Props) { + const checkboxVariants = { + checked: { scale: [1, 1.2, 1], transition: { duration: 0.3 } }, + unchecked: { scale: 1 }, + } + + return ( + + {/* 전체 동의 */} +
+ + {allAgreed && } + + + 모든 약관에 동의합니다 + +
+ + {/* 개별 약관 목록 */} + {terms.map((term, index) => ( + { termRefs.current[term.id] = el }} + className={`flex items-center justify-between py-4 ${ + index < terms.length - 1 ? "border-b border-gray-100 dark:border-gray-700" : "" + }`} + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.3 + index * 0.1 }} + > +
+ onToggleTerm(term.id)} + variants={checkboxVariants} + animate={agreedTerms.includes(term.id) ? "checked" : "unchecked"} + > + {agreedTerms.includes(term.id) && } + +
+ + {term.required ? ( + (필수) + ) : ( + (선택) + )} +
+
+ +
+ ))} + + {/* 다음 버튼 */} + + + +
+ ) +} diff --git a/app/merchant/signup/components/TermsAlert.tsx b/app/merchant/signup/components/TermsAlert.tsx new file mode 100644 index 0000000..d385d73 --- /dev/null +++ b/app/merchant/signup/components/TermsAlert.tsx @@ -0,0 +1,27 @@ +"use client" + +import { motion, AnimatePresence } from "framer-motion" +import { AlertCircle } from "lucide-react" + +interface TermsAlertProps { + show: boolean +} + +export default function TermsAlert({ show }: TermsAlertProps) { + return ( + + {show && ( + + +

모든 필수 약관에 동의해야 진행할 수 있습니다.

+
+ )} +
+ ) +} diff --git a/app/merchant/signup/components/TermsModal.tsx b/app/merchant/signup/components/TermsModal.tsx new file mode 100644 index 0000000..625dfd5 --- /dev/null +++ b/app/merchant/signup/components/TermsModal.tsx @@ -0,0 +1,69 @@ +"use client" + +import { motion, AnimatePresence } from "framer-motion" +import { Button } from "@/components/ui/button" +import { X } from "lucide-react" +import { Term } from "../data/merchant-terms" + +interface Props { + term: Term | null + isVisible: boolean + onClose: () => void + onAgree: () => void +} + +export default function TermsModal({ term, isVisible, onClose, onAgree }: Props) { + const modalVariants = { + hidden: { opacity: 0, y: 50, scale: 0.95 }, + visible: { opacity: 1, y: 0, scale: 1 }, + exit: { opacity: 0, y: 50, scale: 0.95 }, + } + + return ( + + {isVisible && term && ( + + +
+

{term.title}

+ +
+ +
+ {term.content} +
+ +
+ +
+
+
+ )} +
+ ) +} diff --git a/app/merchant/signup/data/merchant-terms.ts b/app/merchant/signup/data/merchant-terms.ts new file mode 100644 index 0000000..c712f71 --- /dev/null +++ b/app/merchant/signup/data/merchant-terms.ts @@ -0,0 +1,188 @@ +export interface Term { + id: string + title: string + required: boolean + content?: string +} + +export const merchantTerms: Term[] = [ + { + id: "merchant-service", + title: "가맹점 서비스 이용약관", + required: true, + content: `제1조 (목적) +이 약관은 Tokkit(이하 "회사"라 함)이 제공하는 가맹점 서비스의 이용조건 및 절차, 회사와 가맹점 간의 권리, 의무, 책임사항 등을 규정함을 목적으로 합니다. + +제2조 (용어의 정의) +1. "가맹점 서비스"란 회사가 제공하는 결제 서비스, 매출 관리 서비스 등을 의미합니다. +2. "가맹점"이란 회사와 서비스 이용계약을 체결하고 회사가 제공하는 가맹점 서비스를 이용하는 사업자를 의미합니다. + +제3조 (약관의 효력 및 변경) +1. 이 약관은 가맹점 서비스를 이용하고자 하는 모든 가맹점에게 적용됩니다. +2. 회사는 필요한 경우 약관을 변경할 수 있으며, 변경된 약관은 서비스 내 공지사항에 게시하거나 기타 방법으로 가맹점에게 공지함으로써 효력이 발생합니다. + +제4조 (서비스의 내용) +1. 회사가 제공하는 가맹점 서비스는 다음 각 호와 같습니다. + 가. 결제 서비스: 고객이 가맹점에서 상품이나 서비스를 구매할 때 결제를 처리하는 서비스 + 나. 매출 관리 서비스: 가맹점의 매출 내역을 관리하고 분석하는 서비스 + 다. 정산 서비스: 가맹점에 결제 금액을 정산하는 서비스 + 라. 기타 회사가 정하는 서비스 +2. 회사는 서비스의 내용을 변경하거나 추가할 수 있으며, 이 경우 회사는 변경 또는 추가 내용을 서비스 내에 공지합니다. + +제5조 (서비스 이용 신청) +1. 가맹점 서비스를 이용하고자 하는 사업자는 회사가 정한 양식에 따라 필요한 정보를 제공하고 이 약관에 동의함으로써 서비스 이용을 신청할 수 있습니다. +2. 회사는 다음 각 호에 해당하는 경우 서비스 이용 신청을 승낙하지 않을 수 있습니다. + 가. 실명이 아니거나 타인의 명의를 이용한 경우 + 나. 허위 정보를 기재하거나 회사가 요구하는 정보를 제공하지 않은 경우 + 다. 관련 법령에 위배되거나 공서양속에 반하는 목적으로 신청한 경우 + 라. 기타 회사가 정한 이용신청 요건이 충족되지 않은 경우 + +제6조 (수수료) +1. 회사는 가맹점에게 결제 금액의 일정 비율을 수수료로 부과할 수 있습니다. +2. 수수료율은 가맹점의 업종, 매출 규모 등에 따라 차등 적용될 수 있으며, 구체적인 수수료율은 별도 계약에 따릅니다. +3. 회사는 필요한 경우 수수료율을 변경할 수 있으며, 변경 시 적용일자 및 변경사유를 명시하여 가맹점에게 사전 통지합니다. + +제7조 (정산) +1. 회사는 가맹점에게 결제 금액에서 수수료를 제외한 금액을 정산합니다. +2. 정산 주기는 일별, 주별, 월별 중 가맹점이 선택할 수 있으며, 구체적인 정산 일정은 별도 계약에 따릅니다. +3. 회사는 다음 각 호에 해당하는 경우 정산을 보류하거나 거절할 수 있습니다. + 가. 고객이 결제를 취소한 경우 + 나. 고객이 결제에 대해 이의를 제기한 경우 + 다. 가맹점이 이 약관을 위반한 경우 + 라. 기타 회사가 정산을 보류하거나 거절할 필요가 있다고 판단한 경우`, + }, + { + id: "merchant-privacy", + title: "개인정보 수집 및 이용 동의", + required: true, + content: `개인정보 수집 및 이용 동의 + +Tokkit(이하 "회사"라 합니다)은 가맹점 서비스 제공을 위해 다음과 같이 개인정보를 수집 및 이용합니다. 내용을 자세히 읽으신 후 동의 여부를 결정하여 주시기 바랍니다. + +1. 수집하는 개인정보 항목 +- 필수항목: 사업자등록번호, 대표자명, 상호명, 사업장 주소, 연락처, 계좌정보 +- 선택항목: 이메일, 홈페이지 주소, 영업시간, 휴무일 + +2. 개인정보 수집 및 이용 목적 +- 서비스 제공 및 계약 이행: 가맹점 서비스 제공, 본인 확인, 정산, 세금계산서 발행 +- 서비스 개선: 신규 서비스 개발, 기존 서비스 개선 +- 고객 관리: 고객 문의 응대, 공지사항 전달 +- 법령 준수: 관련 법령에 따른 의무 이행 + +3. 개인정보의 보유 및 이용 기간 +- 회원 탈퇴 시까지 또는 법정 의무 보유기간까지 +- 전자금융거래법에 따라 전자금융거래에 관한 기록은 5년간 보관 +- 통신비밀보호법에 따라 접속 로그는 3개월간 보관 + +4. 동의 거부권 및 거부 시 불이익 +- 필수항목에 대한 동의를 거부할 경우 서비스 이용이 제한됩니다. +- 선택항목에 대한 동의를 거부하더라도 서비스 이용에 제한은 없으나, 일부 서비스 이용이 제한될 수 있습니다. + +5. 개인정보의 제3자 제공 +- 회사는 이용자의 개인정보를 원칙적으로 외부에 제공하지 않습니다. +- 다만, 아래의 경우에는 예외로 합니다. + 1) 이용자가 사전에 동의한 경우 + 2) 법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 요청한 경우 + +6. 개인정보의 안전성 확보 조치 +- 회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취하고 있습니다. + 1) 관리적 조치: 내부관리계획 수립 및 시행, 정기적 직원 교육 + 2) 기술적 조치: 개인정보처리시스템 접근 제한, 암호화 기술 적용, 접속 기록 보관 + 3) 물리적 조치: 전산실, 자료보관실 등의 접근 통제 + +7. 이용자의 권리와 행사 방법 +- 이용자는 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다. +- 권리 행사는 회사에 대해 서면, 전화, 전자우편, 모사전송(FAX) 등을 통하여 하실 수 있으며 회사는 이에 대해 지체 없이 조치하겠습니다. + +8. 개인정보 보호책임자 및 연락처 +- 개인정보 보호책임자: 홍길동 +- 연락처: privacy@tokkit.com, 02-123-4567`, + }, + { + id: "merchant-financial", + title: "전자금융거래 이용약관 동의", + required: true, + content: `전자금융거래 이용약관 + +제1조 (목적) +이 약관은 Tokkit(이하 "회사"라 함)이 제공하는 전자금융거래 서비스의 이용조건 및 절차, 회사와 가맹점 간의 권리, 의무, 책임사항 등을 규정함을 목적으로 합니다. + +제2조 (용어의 정의) +1. "전자금융거래"란 회사가 전자적 장치를 통하여 금융상품 및 서비스를 제공하고, 가맹점이 회사의 종사자와 직접 대면하거나 의사소통을 하지 아니하고 자동화된 방식으로 이를 이용하는 거래를 말합니다. +2. "전자지급수단"이란 전자자금이체, 직불전자지급수단, 선불전자지급수단, 전자화폐, 신용카드, 전자채권 등 전자적 방법에 따른 지급수단을 말합니다. +3. "전자적 장치"란 전자금융거래정보를 전자적 방법으로 전송하거나 처리하는데 이용되는 장치로서 현금자동지급기, 자동입출금기, 지급용단말기, 컴퓨터, 전화기 등을 말합니다. + +제3조 (약관의 효력 및 변경) +1. 이 약관은 가맹점이 이에 동의함으로써 효력이 발생합니다. +2. 회사는 필요한 경우 약관을 변경할 수 있으며, 변경된 약관은 서비스 내 공지사항에 게시하거나 기타 방법으로 가맹점에게 공지함으로써 효력이 발생합니다. +3. 가맹점이 변경된 약관에 동의하지 않는 경우 서비스 이용을 중단하고 이용계약을 해지할 수 있습니다. + +제4조 (전자금융거래의 방법 및 절차) +1. 가맹점은 회사가 정한 방법에 따라 전자금융거래를 이용할 수 있습니다. +2. 회사는 가맹점의 전자금융거래 신청이 있는 경우 가맹점에게 약관에 정한 방법으로 승낙함으로써 전자금융거래가 성립합니다. +3. 회사는 가맹점의 전자금융거래 신청에 대하여 가맹점이 약관에 정한 사항을 위반하는 경우 또는 기타 정당한 사유가 있는 경우에는 이를 승낙하지 않을 수 있습니다. + +제5조 (수수료) +1. 회사는 가맹점에게 결제 금액의 일정 비율을 수수료로 부과할 수 있습니다. +2. 수수료율은 가맹점의 업종, 매출 규모 등에 따라 차등 적용될 수 있으며, 구체적인 수수료율은 별도 계약에 따릅니다. +3. 회사는 필요한 경우 수수료율을 변경할 수 있으며, 변경 시 적용일자 및 변경사유를 명시하여 가맹점에게 사전 통지합니다. + +제6조 (정산) +1. 회사는 가맹점에게 결제 금액에서 수수료를 제외한 금액을 정산합니다. +2. 정산 주기는 일별, 주별, 월별 중 가맹점이 선택할 수 있으며, 구체적인 정산 일정은 별도 계약에 따릅니다. +3. 회사는 다음 각 호에 해당하는 경우 정산을 보류하거나 거절할 수 있습니다. + 가. 고객이 결제를 취소한 경우 + 나. 고객이 결제에 대해 이의를 제기한 경우 + 다. 가맹점이 이 약관을 위반한 경우 + 라. 기타 회사가 정산을 보류하거나 거절할 필요가 있다고 판단한 경우 + +제7조 (거래지시의 철회) +1. 가맹점이 전자금융거래를 위한 거래지시를 한 경우 이를 철회할 수 없습니다. +2. 제1항에도 불구하고 회사와 가맹점 간 별도 약정이 있는 경우에는 그 약정에 따릅니다. + +제8조 (전자금융거래의 기록 보존 및 제공) +1. 회사는 전자금융거래의 내용을 추적, 검색하거나 그 내용에 오류가 발생한 경우에 이를 확인하거나 정정할 수 있는 기록을 생성하여 보존합니다. +2. 제1항의 규정에 따라 회사가 보존하여야 하는 기록의 종류, 보존방법 및 보존기간은 전자금융거래법 제22조 및 같은 법 시행령 제12조에 따릅니다. + +제9조 (전자금융거래의 안전성 확보) +1. 회사는 전자금융거래의 안전성과 신뢰성을 확보하기 위하여 전자금융거래의 종류별로 전자적 전송이나 처리를 위한 인력, 시설, 전자적 장치 등의 정보기술부문 및 전자금융업무에 관하여 금융위원회가 정하는 기준을 준수합니다. +2. 회사는 가맹점의 전자금융거래 신청 및 이용 과정에서 가맹점이 제공한 정보를 보호하기 위하여 필요한 조치를 취합니다.`, + }, + { + id: "merchant-marketing", + title: "마케팅 정보 수신 동의", + required: false, + content: `마케팅 정보 수신 동의 + +Tokkit(이하 "회사"라 합니다)은 가맹점에게 유용한 서비스 및 이벤트 정보를 제공하기 위해 다음과 같이 마케팅 정보를 수신하는 것에 대한 동의를 받고 있습니다. 내용을 자세히 읽으신 후 동의 여부를 결정하여 주시기 바랍니다. + +1. 마케팅 정보 수신 동의 내용 +- 회사가 제공하는 서비스, 이벤트, 프로모션 등의 광고성 정보 + +2. 마케팅 정보 수신 방법 +- 이메일, SMS, 푸시 알림, 우편 등 + +3. 마케팅 정보 수신 목적 +- 신규 서비스 및 이벤트 안내 +- 맞춤형 혜택 및 프로모션 제공 +- 서비스 개선을 위한 의견 수렴 + +4. 마케팅 정보 수신 동의의 효력 기간 +- 회원 탈퇴 시 또는 마케팅 정보 수신 동의 철회 시까지 + +5. 동의 거부권 및 거부 시 불이익 +- 본 마케팅 정보 수신 동의는 선택사항으로, 동의를 거부하더라도 기본적인 서비스 이용에는 제한이 없습니다. +- 다만, 동의하지 않을 경우 회사가 제공하는 각종 혜택 및 이벤트 정보를 받아보실 수 없습니다. + +6. 마케팅 정보 수신 동의 철회 방법 +- 가맹점은 언제든지 마케팅 정보 수신 동의를 철회할 수 있습니다. +- 철회 방법: 서비스 내 '설정 > 알림 설정'에서 변경 또는 고객센터(1588-1234)로 요청 + +7. 개인정보의 제3자 제공 및 위탁 +- 회사는 마케팅 활동을 위해 필요한 경우 이용자의 개인정보를 외부 전문업체에 위탁할 수 있습니다. +- 위탁 업체 및 위탁 업무 내용은 회사 홈페이지의 '개인정보처리방침'에서 확인하실 수 있습니다. + +8. 마케팅 정보 관련 문의 +- 마케팅 정보 수신과 관련하여 문의사항이 있으신 경우, 고객센터(1588-1234) 또는 이메일(marketing@tokkit.com)로 문의해 주시기 바랍니다.`, + }, +] \ No newline at end of file diff --git a/app/merchant/signup/page.tsx b/app/merchant/signup/page.tsx new file mode 100644 index 0000000..b5eeeeb --- /dev/null +++ b/app/merchant/signup/page.tsx @@ -0,0 +1,158 @@ + "use client" + + import { useRouter } from "next/navigation" + import { motion } from "framer-motion" + import { useState, useEffect, useRef } from "react" + import { merchantTerms } from "./data/merchant-terms" + import MerchantTermsHeader from "./components/MerchantTermsHeader" + import TermsAlert from "./components/TermsAlert" + import TermsAgreementCard from "./components/TermsAgreementCard" + import TermsModal from "./components/TermsModal" + + export interface Term { + id: string + title: string + required: boolean + content?: string + } + + export default function MerchantSignupPage() { + const router = useRouter() + const [terms] = useState(merchantTerms) + const [agreedTerms, setAgreedTerms] = useState([]) + const [allAgreed, setAllAgreed] = useState(false) + const [showAlert, setShowAlert] = useState(false) + const [currentTermIndex, setCurrentTermIndex] = useState(null) + const [viewedTerms, setViewedTerms] = useState([]) + const [isAllTermsFlow, setIsAllTermsFlow] = useState(false) + const termRefs = useRef>({}) + const initialRenderRef = useRef(true) + + useEffect(() => { + if (initialRenderRef.current) { + initialRenderRef.current = false + return + } + const isAllAgreed = terms.every((term) => agreedTerms.includes(term.id)) + setAllAgreed(isAllAgreed) + }, [agreedTerms, terms]) + + const handleToggleTerm = (termId: string) => { + if (!viewedTerms.includes(termId)) { + const termIndex = terms.findIndex((term) => term.id === termId) + if (termIndex !== -1) { + setCurrentTermIndex(termIndex) + setIsAllTermsFlow(false) + } + return + } + setAgreedTerms((prev) => + prev.includes(termId) ? prev.filter((id) => id !== termId) : [...prev, termId] + ) + } + + const handleToggleAll = () => { + if (allAgreed) { + setAgreedTerms([]) + } else { + const allViewed = terms.every((term) => viewedTerms.includes(term.id)) + if (allViewed) { + setAgreedTerms(terms.map((term) => term.id)) + } else { + setIsAllTermsFlow(true) + const firstNotViewed = terms.findIndex((term) => !viewedTerms.includes(term.id)) + setCurrentTermIndex(firstNotViewed !== -1 ? firstNotViewed : 0) + } + } + } + + const handleViewTerm = (termId: string) => { + const termIndex = terms.findIndex((term) => term.id === termId) + if (termIndex !== -1) { + setCurrentTermIndex(termIndex) + setIsAllTermsFlow(false) + } + } + + const handleAgreeCurrentTerm = () => { + if (currentTermIndex !== null) { + const currentTerm = terms[currentTermIndex] + if (!viewedTerms.includes(currentTerm.id)) setViewedTerms((prev) => [...prev, currentTerm.id]) + if (!agreedTerms.includes(currentTerm.id)) setAgreedTerms((prev) => [...prev, currentTerm.id]) + if (isAllTermsFlow && currentTermIndex < terms.length - 1) { + setCurrentTermIndex(currentTermIndex + 1) + } else { + setCurrentTermIndex(null) + setIsAllTermsFlow(false) + if (isAllTermsFlow) setAgreedTerms(terms.map((term) => term.id)) + } + } + } + + const handleCloseTermModal = () => { + if (currentTermIndex !== null) { + const currentTerm = terms[currentTermIndex] + if (!viewedTerms.includes(currentTerm.id)) setViewedTerms((prev) => [...prev, currentTerm.id]) + } + setCurrentTermIndex(null) + setIsAllTermsFlow(false) + } + + const handleSubmit = () => { + const requiredTerms = terms.filter((term) => term.required).map((term) => term.id) + const allRequiredAgreed = requiredTerms.every((termId) => agreedTerms.includes(termId)) + if (allRequiredAgreed) { + router.push("/merchant/signup/business") + } else { + setShowAlert(true) + setTimeout(() => setShowAlert(false), 3000) + } + } + + return ( + + {/* 상단 고정 헤더 */} + + + {/* 헤더 제외 전체 중앙 정렬 영역 */} +
+
+ {/* 제목 및 설명 */} +
+

가맹점 약관 동의

+

안전한 가맹점 서비스 이용을 위해 아래 약관에 동의해주세요.

+
+ + {/* 경고 및 약관 카드 */} + + +
+
+ + {/* 모달 */} + +
+ ) + } diff --git a/app/merchant/signup/wallet/complete/components/AccountNumberCard.tsx b/app/merchant/signup/wallet/complete/components/AccountNumberCard.tsx new file mode 100644 index 0000000..1c6a166 --- /dev/null +++ b/app/merchant/signup/wallet/complete/components/AccountNumberCard.tsx @@ -0,0 +1,34 @@ +import { motion } from "framer-motion"; +import { Store, Wallet } from "lucide-react"; + +interface Props { + accountNumber: string; + isMerchant: boolean; + businessName: string; +} + +export default function AccountNumberCard({ + accountNumber, + isMerchant, + businessName, + }: Props) { + return ( + +
+
+
+ {isMerchant ? : } +
+
+

전자지갑 계좌번호

+

{isMerchant ? `${businessName} 가맹점` : "Tokkit 전자지갑"}

+
+
+ +
+

{accountNumber}

+
+
+
+ ); +} diff --git a/app/merchant/signup/wallet/complete/components/CompleteFooterButton.tsx b/app/merchant/signup/wallet/complete/components/CompleteFooterButton.tsx new file mode 100644 index 0000000..e7ab593 --- /dev/null +++ b/app/merchant/signup/wallet/complete/components/CompleteFooterButton.tsx @@ -0,0 +1,26 @@ +import { Button } from "@/components/ui/button"; +import { ArrowRight } from "lucide-react"; +import { useRouter } from "next/navigation"; + +interface Props { + isMerchant: boolean; +} + +export default function CompleteFooterButton({ isMerchant }: Props) { + const router = useRouter(); + + return ( +
+ +
+ ); +} diff --git a/app/merchant/signup/wallet/complete/components/UsageGuideList.tsx b/app/merchant/signup/wallet/complete/components/UsageGuideList.tsx new file mode 100644 index 0000000..2b657c0 --- /dev/null +++ b/app/merchant/signup/wallet/complete/components/UsageGuideList.tsx @@ -0,0 +1,47 @@ +interface Props { + isMerchant: boolean; + businessName: string; +} + +export default function UsageGuideList({ isMerchant }: Props) { + return ( +
+
+

이용 안내

+
    + {isMerchant ? ( + <> +
  • + 1 +

    가맹점 대시보드에서 결제 내역을 확인할 수 있습니다.

    +
  • +
  • + 2 +

    정산 내역은 정산 페이지에서 확인 가능합니다.

    +
  • +
  • + 3 +

    QR코드를 통해 간편하게 결제를 받을 수 있습니다.

    +
  • + + ) : ( + <> +
  • + 1 +

    전자지갑을 통해 바우처를 관리하고 결제할 수 있습니다.

    +
  • +
  • + 2 +

    지갑 페이지에서 잔액 확인 및 충전이 가능합니다.

    +
  • +
  • + 3 +

    바우처 사용 내역은 거래 내역에서 확인할 수 있습니다.

    +
  • + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/merchant/signup/wallet/complete/components/WalletCompleteContent.tsx b/app/merchant/signup/wallet/complete/components/WalletCompleteContent.tsx new file mode 100644 index 0000000..9ee1122 --- /dev/null +++ b/app/merchant/signup/wallet/complete/components/WalletCompleteContent.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; +import AccountNumberCard from "./AccountNumberCard"; +import UsageGuideList from "./UsageGuideList"; +import CompleteFooterButton from "./CompleteFooterButton"; +import confetti from "canvas-confetti" // 테무폭죽 Import + +export default function WalletCompleteContent() { + const router = useRouter(); + const [accountNumber, setAccountNumber] = useState(""); + const [isMerchant, setIsMerchant] = useState(false); + const [businessName, setBusinessName] = useState(""); + + // 테무 폭죽 + useEffect(() => { + const duration = 3 * 1000 + const animationEnd = Date.now() + duration + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 } + + function randomInRange(min: number, max: number) { + return Math.random() * (max - min) + min + } + + const interval: NodeJS.Timeout = setInterval(() => { + const timeLeft = animationEnd - Date.now() + + if (timeLeft <= 0) { + return clearInterval(interval) + } + + const particleCount = 50 * (timeLeft / duration) + + // 왼쪽과 오른쪽에서 컨페티 발사 + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, + }) + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, + }) + }, 250) + + return () => clearInterval(interval) + }, []) + + useEffect(() => { + const storedAccountNumber = sessionStorage.getItem("accountNumber"); + if (storedAccountNumber) { + setAccountNumber(storedAccountNumber); + } + + const userType = sessionStorage.getItem("userType"); + if (userType === "merchant") { + setIsMerchant(true); + const businessInfoStr = sessionStorage.getItem("businessInfo"); + if (businessInfoStr) { + try { + const businessInfo = JSON.parse(businessInfoStr); + setBusinessName(businessInfo.businessName || ""); + } catch (e) { + console.error("Failed to parse business info", e); + } + } + } + }, []); + + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { when: "beforeChildren", staggerChildren: 0.1 }, + }, + }; + + const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { y: 0, opacity: 1 }, + }; + + return ( + + + Tokkit 로고 + + + + 마스코트 + + + +

전자지갑 개설 완료

+

+ {isMerchant + ? `${businessName} 가맹점의 전자지갑이 성공적으로 개설되었습니다.` + : "전자지갑이 성공적으로 개설되었습니다."} +

+
+ + + + + + +
+ ); +} diff --git a/app/merchant/signup/wallet/complete/page.tsx b/app/merchant/signup/wallet/complete/page.tsx new file mode 100644 index 0000000..031dbad --- /dev/null +++ b/app/merchant/signup/wallet/complete/page.tsx @@ -0,0 +1,5 @@ +import WalletCompleteContent from "./components/WalletCompleteContent"; + +export default function WalletCompletePage() { + return ; +} diff --git a/app/merchant/signup/wallet/components/wallet-intro.tsx b/app/merchant/signup/wallet/components/wallet-intro.tsx new file mode 100644 index 0000000..f52db38 --- /dev/null +++ b/app/merchant/signup/wallet/components/wallet-intro.tsx @@ -0,0 +1,120 @@ +"use client" + +import { useRouter } from "next/navigation" +import Image from "next/image" +import { motion } from "framer-motion" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +export default function WalletIntro() { + const router = useRouter() + + return ( + + {/* Header */} +
+ + + 전자지갑 개설 + +
+ + {/* Content */} +
+ +
+ Tokkit 마스코트 +
+
+ + + 전자지갑 개설 및 회원가입 + + + + Tokkit 서비스 이용을 위한 전자지갑 개설과 회원가입을 진행합니다 + + + +
+
+

전자지갑 개설 절차

+
    + {steps.map((step, idx) => ( +
  1. +
    + {idx + 1} +
    +
    +

    {step.title}

    +

    {step.description}

    +
    +
  2. + ))} +
+
+ + +
+
+
+
+ ) +} + +const steps = [ + { + title: "약관 동의", + description: "서비스 이용 및 전자지갑 개설을 위한 약관 동의" + }, + { + title: "본인인증", + description: "이름, 주민등록번호, 주민등록증 발급일자를 통한 본인인증" + }, + { + title: "연락처 정보 입력", + description: "이메일, 전화번호 입력 및 인증" + }, + { + title: "간편 비밀번호 설정", + description: "전자지갑 이용을 위한 간편 비밀번호 설정" + } +] diff --git a/app/merchant/signup/wallet/contact/api/register-auth.ts b/app/merchant/signup/wallet/contact/api/register-auth.ts new file mode 100644 index 0000000..a11492b --- /dev/null +++ b/app/merchant/signup/wallet/contact/api/register-auth.ts @@ -0,0 +1,43 @@ +import axios from "axios"; + +import { getApiUrl } from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +// 이메일 인증 요청: 쿼리 파라미터 사용 +export const sendEmailMerchantVerificationCode = async (email: string) => { + const response = await axios.post(`${API_URL}/api/merchants/emailCheck`, null, { + params: { email }, + }); + return response.data; +}; + +// 이메일 인증번호 검증: JSON body 사용 +export const verifyMerchantEmailCode = async (email: string, code: string) => { + const response = await axios.post(`${API_URL}/api/merchants/verification`, { + email, + verification: code, + }); + return response.data; +}; + +// 회원가입 요청: JSON body로 보내기 +export interface CreateMerchantRequest { + name: string + email: string + phoneNumber: string + password: string + simplePassword: string + businessNumber: string + + storeName: string + roadAddress: string + sidoName: string + sigunguName: string + storeCategory: string +} + +export const submitMerchantContactInfo = async (data: CreateMerchantRequest) => { + const response = await axios.post(`${API_URL}/api/merchants/register`, data) + return response.data +} diff --git a/app/merchant/signup/wallet/contact/components/EmailVerificationBlock.tsx b/app/merchant/signup/wallet/contact/components/EmailVerificationBlock.tsx new file mode 100644 index 0000000..f003de3 --- /dev/null +++ b/app/merchant/signup/wallet/contact/components/EmailVerificationBlock.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Clock } from "lucide-react"; + +interface Props { + email: string; + setEmail: (val: string) => void; + verificationCode: string; + setVerificationCode: (val: string) => void; + isEmailSent: boolean; + isVerified: boolean; + remainingTime: number; + handleSendVerification: () => void; + handleVerifyCode: () => void; + isLoading: boolean; +} + +export default function EmailVerificationBlock({ + email, + setEmail, + verificationCode, + setVerificationCode, + isEmailSent, + isVerified, + remainingTime, + handleSendVerification, + handleVerifyCode, + isLoading, + }: Props) { + const verificationCodeRef = useRef(null); + + useEffect(() => { + if (isEmailSent && verificationCodeRef.current) { + verificationCodeRef.current.focus(); + } + }, [isEmailSent]); + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs < 10 ? "0" : ""}${secs}`; + }; + + const handleVerificationCodeChange = (e: React.ChangeEvent) => { + const value = e.target.value; // 영문자도 허용 + setVerificationCode(value.slice(0, 6)); + }; + + return ( +
+ +
+ setEmail(e.target.value)} + required + disabled={isVerified} + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + /> + +
+ + {isEmailSent && !isVerified && ( +
+
+ +
+ + {formatTime(remainingTime)} +
+
+
+ + +
+

이메일로 전송된 6자리 인증 코드를 입력하세요.

+
+ )} +
+ ); +} diff --git a/app/merchant/signup/wallet/contact/components/FormFeedbackMessage.tsx b/app/merchant/signup/wallet/contact/components/FormFeedbackMessage.tsx new file mode 100644 index 0000000..13e72cf --- /dev/null +++ b/app/merchant/signup/wallet/contact/components/FormFeedbackMessage.tsx @@ -0,0 +1,27 @@ +import { AlertCircle, CheckCircle2 } from "lucide-react"; +import { motion } from "framer-motion"; + +interface Props { + error?: string | null; + success?: string | null; +} + +export default function FormFeedbackMessage({ error, success }: Props) { + if (!error && !success) return null; + + return ( + + {error ? ( + + ) : ( + + )} +

{error || success}

+
+ ); +} \ No newline at end of file diff --git a/app/merchant/signup/wallet/contact/components/PasswordInputBlock.tsx b/app/merchant/signup/wallet/contact/components/PasswordInputBlock.tsx new file mode 100644 index 0000000..07f6a5d --- /dev/null +++ b/app/merchant/signup/wallet/contact/components/PasswordInputBlock.tsx @@ -0,0 +1,134 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Eye, EyeOff, Lock, Check, X } from "lucide-react"; +import React from "react"; + +interface PasswordValidation { + hasLength: boolean; + hasUpperCase: boolean; + hasLowerCase: boolean; + hasSpecialChar: boolean; + passwordsMatch: boolean; +} + +interface Props { + password: string; + confirmPassword: string; + setPassword: (val: string) => void; + setConfirmPassword: (val: string) => void; + showPassword: boolean; + setShowPassword: (val: boolean) => void; + showConfirmPassword: boolean; + setShowConfirmPassword: (val: boolean) => void; + passwordValidation: PasswordValidation; + passwordRef: React.RefObject; + confirmPasswordRef: React.RefObject; +} + +export default function PasswordInputBlock({ + password, + confirmPassword, + setPassword, + setConfirmPassword, + showPassword, + setShowPassword, + showConfirmPassword, + setShowConfirmPassword, + passwordValidation, + passwordRef, + confirmPasswordRef, + }: Props) { + return ( + <> +
+ +
+ setPassword(e.target.value)} + required + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0 pr-10" + ref={passwordRef} + /> + +
+ + {/* 비밀번호 유효성 표시 */} +
+
+ {passwordValidation.hasLength ? : } + 8자 이상 +
+
+ {passwordValidation.hasUpperCase ? : } + 대문자 포함 +
+
+ {passwordValidation.hasLowerCase ? : } + 소문자 포함 +
+
+ {passwordValidation.hasSpecialChar ? : } + 특수문자 포함 +
+
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + required + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0 pr-10" + ref={confirmPasswordRef} + /> + +
+ + {/* 비밀번호 일치 여부 표시 */} + {confirmPassword && ( +
+ {passwordValidation.passwordsMatch ? ( + <> + 비밀번호가 일치합니다. + + ) : ( + <> + 비밀번호가 일치하지 않습니다. + + )} +
+ )} +
+ + ); +} diff --git a/app/merchant/signup/wallet/contact/components/PhoneInputBlock.tsx b/app/merchant/signup/wallet/contact/components/PhoneInputBlock.tsx new file mode 100644 index 0000000..9a5109e --- /dev/null +++ b/app/merchant/signup/wallet/contact/components/PhoneInputBlock.tsx @@ -0,0 +1,28 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import React from "react"; + +interface Props { + phoneNumber: string; + onChange: (e: React.ChangeEvent) => void; + phoneRef: React.RefObject; +} + +export default function PhoneInputBlock({ phoneNumber, onChange, phoneRef }: Props) { + return ( +
+ + +
+ ); +} diff --git a/app/merchant/signup/wallet/contact/components/SubmitButton.tsx b/app/merchant/signup/wallet/contact/components/SubmitButton.tsx new file mode 100644 index 0000000..f35ea5e --- /dev/null +++ b/app/merchant/signup/wallet/contact/components/SubmitButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "@/components/ui/button"; + +interface Props { + isLoading: boolean; + isDisabled: boolean; + onClick: () => void; + label?: string; +} + +export default function SubmitButton({ isLoading, isDisabled, onClick, label = "다음" }: Props) { + return ( + + ); +} \ No newline at end of file diff --git a/app/merchant/signup/wallet/contact/page.tsx b/app/merchant/signup/wallet/contact/page.tsx new file mode 100644 index 0000000..3ba07da --- /dev/null +++ b/app/merchant/signup/wallet/contact/page.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { motion } from "framer-motion"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Mail } from "lucide-react"; + +import EmailVerificationBlock from "@/app/merchant/signup/wallet/contact/components/EmailVerificationBlock"; +import PasswordInputBlock from "@/app/merchant/signup/wallet/contact/components/PasswordInputBlock"; +import PhoneInputBlock from "@/app/merchant/signup/wallet/contact/components/PhoneInputBlock"; +import FormFeedbackMessage from "@/app/merchant/signup/wallet/contact/components/FormFeedbackMessage"; +import SubmitButton from "@/app/merchant/signup/wallet/contact/components/SubmitButton"; +import { sendEmailMerchantVerificationCode, verifyMerchantEmailCode, submitMerchantContactInfo } from "@/app/merchant/signup/wallet/contact/api/register-auth"; + +export default function WalletContactPage() { + const router = useRouter(); + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [verificationCode, setVerificationCode] = useState(""); + const [isEmailSent, setIsEmailSent] = useState(false); + const [isVerified, setIsVerified] = useState(false); + const [remainingTime, setRemainingTime] = useState(300); + const [verificationAttempts, setVerificationAttempts] = useState(0); + + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const [phoneNumber, setPhone] = useState(""); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const passwordRef = useRef(null); + const confirmPasswordRef = useRef(null); + const phoneRef = useRef(null); + + const passwordValidation = { + hasLength: password.length >= 8, + hasUpperCase: /[A-Z]/.test(password), + hasLowerCase: /[a-z]/.test(password), + hasSpecialChar: /[!@#$%^&*(),.?":{}|<>]/.test(password), + passwordsMatch: password === confirmPassword && password !== "", + }; + + const isPasswordValid = + passwordValidation.hasLength && + passwordValidation.hasUpperCase && + passwordValidation.hasLowerCase && + passwordValidation.hasSpecialChar; + + useEffect(() => { + const storedName = sessionStorage.getItem("verifiedName"); + if (storedName) { + setName(storedName); + } + + let timer: NodeJS.Timeout; + if (isEmailSent && remainingTime > 0) { + timer = setInterval(() => { + setRemainingTime((prev) => prev - 1); + }, 1000); + } else if (remainingTime === 0 && isEmailSent) { + setIsEmailSent(false); + setError("인증 시간이 만료되었습니다. 다시 인증해주세요."); + } + + return () => clearInterval(timer); + }, [isEmailSent, remainingTime]); + + const handleSendVerification = async () => { + setIsLoading(true); + setError(null); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + setError("유효한 이메일 주소를 입력해주세요."); + setIsLoading(false); + return; + } + try { + await sendEmailMerchantVerificationCode(email); + setIsEmailSent(true); + setSuccess("인증 코드가 이메일로 전송되었습니다."); + setRemainingTime(300); + setVerificationAttempts((prev) => prev + 1); + if (verificationAttempts >= 4) { + setError("최대 인증 시도 횟수를 초과했습니다. 24시간 후에 다시 시도해주세요."); + setIsEmailSent(false); + } + } catch { + setError("인증 코드 전송에 실패했습니다. 다시 시도해주세요."); + } finally { + setIsLoading(false); + } + }; + + const handleVerifyCode = async () => { + setIsLoading(true); + setError(null); + try { + await verifyMerchantEmailCode(email, verificationCode); + setIsVerified(true); + setIsEmailSent(false); + setSuccess("이메일 인증이 완료되었습니다."); + } catch { + setError("인증에 실패했습니다. 다시 시도해주세요."); + } finally { + setIsLoading(false); + } + }; + + const handlePhoneChange = (e: React.ChangeEvent) => { + const raw = e.target.value.replace(/[^0-9]/g, ""); + const formatted = + raw.length <= 3 + ? raw + : raw.length <= 7 + ? `${raw.slice(0, 3)}-${raw.slice(3)}` + : `${raw.slice(0, 3)}-${raw.slice(3, 7)}-${raw.slice(7, 11)}`; + setPhone(formatted); + }; + + const handleNext = () => { + setError(null); + setSuccess(null); + + if (!isVerified) return setError("이메일 인증을 완료해주세요."); + if (!isPasswordValid) return setError("비밀번호는 8자 이상, 대문자, 소문자, 특수문자를 포함해야 합니다."); + if (!passwordValidation.passwordsMatch) return setError("비밀번호가 일치하지 않습니다."); + if (!/^[0-9]{10,11}$/.test(phoneNumber.replace(/-/g, ""))) return setError("유효한 전화번호를 입력해주세요."); + + const payload = { email, password, phoneNumber, name, }; + sessionStorage.setItem("signupPayload", JSON.stringify(payload)); + + router.push("/merchant/signup/wallet/password"); + }; + + + return ( + +
+ + + 연락처 정보 + +
+ +
+
+ +
+ +
+

연락처 정보

+

전자지갑 개설을 위한 연락처 정보를 입력해주세요.

+
+ + + + + {isVerified && ( + <> + } + confirmPasswordRef={confirmPasswordRef as React.RefObject} + /> + } /> + + )} + + + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/merchant/signup/wallet/page.tsx b/app/merchant/signup/wallet/page.tsx new file mode 100644 index 0000000..0610857 --- /dev/null +++ b/app/merchant/signup/wallet/page.tsx @@ -0,0 +1,5 @@ +import WalletIntro from "@/app/merchant/signup/wallet/components/wallet-intro" + +export default function WalletIntroPage() { + return +} diff --git a/app/merchant/signup/wallet/password/components/SimplePasswordStep.tsx b/app/merchant/signup/wallet/password/components/SimplePasswordStep.tsx new file mode 100644 index 0000000..ce668b0 --- /dev/null +++ b/app/merchant/signup/wallet/password/components/SimplePasswordStep.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import VirtualKeypad from "@/components/virtual-keypad"; + +interface Props { + onComplete: (password: string) => void; + isLoading?: boolean; +} + +export default function SimplePasswordStep({ onComplete, isLoading = false }: Props) { + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [step, setStep] = useState<"first" | "confirm">("first"); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const handleKeypadComplete = (pinCode: string) => { + if (step === "first") { + setPassword(pinCode); + setStep("confirm"); + } else { + setConfirmPassword(pinCode); + + if (pinCode !== password) { + setError("비밀번호가 일치하지 않습니다."); + setTimeout(() => { + setConfirmPassword(""); + setError(null); + setStep("first"); + }, 1500); + return; + } + + setSuccess("비밀번호가 설정되었습니다."); + onComplete(pinCode); + } + }; + + const resetPassword = () => { + setPassword(""); + setConfirmPassword(""); + setStep("first"); + setError(null); + setSuccess(null); + }; + + return ( +
+ + + {step == "first" && ( + +

간편 비밀번호 입력

+
+ )} + {step === "confirm" && ( + +

간편 비밀번호 재입력

+
+ )} + + +
+
+ + {/* 메시지 */} + + {error && ( + + +

{error}

+
+ )} +
+ + + {success && ( + + +

{success}

+
+ )} +
+ + {step === "confirm" && !isLoading && ( + + + + )} +
+ ); +} diff --git a/app/merchant/signup/wallet/password/page.tsx b/app/merchant/signup/wallet/password/page.tsx new file mode 100644 index 0000000..21a45dc --- /dev/null +++ b/app/merchant/signup/wallet/password/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import SimplePasswordStep from "@/app/merchant/signup/wallet/password/components/SimplePasswordStep"; +import { submitMerchantContactInfo } from "@/app/merchant/signup/wallet/contact/api/register-auth"; +import Link from "next/link"; +import Image from "next/image"; + +export default function PasswordSetupPage() { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (simplePassword: string) => { + try { + const user = JSON.parse(sessionStorage.getItem("signupPayload") || "{}") + const business = JSON.parse(sessionStorage.getItem("businessInfo") || "{}") + + // 필수 값 누락 검사 + if ( + !user.email || !user.password || !user.phoneNumber || !user.name || + !business.businessNumber || !business.storeName || !business.roadAddress || + !business.sido || !business.sigungu || !business.category + ) { + alert("입력 정보가 유실되었습니다. 다시 시도해주세요.") + return router.push("/merchant/signup/wallet/contact") + } + + setIsLoading(true) + + const response = await submitMerchantContactInfo({ + name: user.name, + email: user.email, + password: user.password, + phoneNumber: user.phoneNumber, + simplePassword, + + businessNumber: business.businessNumber, + storeName: business.storeName, + roadAddress: business.roadAddress, + sidoName: business.sido, + sigunguName: business.sigungu, + storeCategory: business.category, + }) + + console.log("회원가입 응답:", response) + + const accountNumber = response.result.accountNumber + sessionStorage.setItem("accountNumber", accountNumber) + + sessionStorage.removeItem("signupPayload") + sessionStorage.removeItem("businessInfo") + sessionStorage.removeItem("verifiedName") + + router.push("/merchant/signup/wallet/complete") + } catch (err) { + console.error(err) + alert("회원가입에 실패했습니다. 다시 시도해주세요.") + } finally { + setIsLoading(false) + } + } + + return ( + +
+ + + 간편 비밀번호 설정 + +
+ + +
+
+
+ + Tokkit Logo + +
+ + +
+
+
+
+ ); +} diff --git a/app/merchant/signup/wallet/terms/[id]/page.tsx b/app/merchant/signup/wallet/terms/[id]/page.tsx new file mode 100644 index 0000000..5874ee7 --- /dev/null +++ b/app/merchant/signup/wallet/terms/[id]/page.tsx @@ -0,0 +1,149 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { useRouter, useParams, useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { ArrowLeft, Check } from "lucide-react" +import { motion } from "framer-motion" +import { walletTerms as termsData } from "@/app/merchant/signup/wallet/terms/data/walletTerms" + +interface TermContent { + id: string + title: string + content: string +} + +export default function TermDetailPage() { + const router = useRouter() + const params = useParams() + const searchParams = useSearchParams() + const termId = params.id as string + const [term, setTerm] = useState(null) + const [isScrolledToBottom, setIsScrolledToBottom] = useState(false) + const contentRef = useRef(null) + const scrollCheckTimeoutRef = useRef(null) + + const getAgreedTerms = () => { + const agreedParam = searchParams.get("agreed") + return agreedParam ? agreedParam.split(",") : [] + } + + useEffect(() => { + const foundTerm = termsData.find((t) => t.id === termId) + if (foundTerm) { + setTerm(foundTerm) + } else { + router.push("/merchant/signup/wallet/terms") + } + }, [termId, router]) + + useEffect(() => { + const currentRef = contentRef.current + + const checkInitialScroll = () => { + if (currentRef) { + const { scrollHeight, clientHeight } = currentRef + if (scrollHeight <= clientHeight) { + setIsScrolledToBottom(true) + } + } + } + + const handleScrollEvent = () => { + if (currentRef) { + const { scrollTop, scrollHeight, clientHeight } = currentRef + if (scrollTop + clientHeight >= scrollHeight * 0.8) { + setIsScrolledToBottom(true) + } + } + } + + if (currentRef) { + if (scrollCheckTimeoutRef.current) { + clearTimeout(scrollCheckTimeoutRef.current) + } + scrollCheckTimeoutRef.current = setTimeout(() => { + checkInitialScroll() + }, 300) + + currentRef.addEventListener("scroll", handleScrollEvent) + } + + return () => { + if (currentRef) { + currentRef.removeEventListener("scroll", handleScrollEvent) + } + if (scrollCheckTimeoutRef.current) { + clearTimeout(scrollCheckTimeoutRef.current) + } + } + }, [term]) + + const handleAgree = () => { + const agreedTerms = getAgreedTerms() + const updatedTerms = [...agreedTerms] + if (!updatedTerms.includes(termId)) { + updatedTerms.push(termId) + } + router.push(`/merchant/signup/wallet/terms?agreed=${updatedTerms.join(",")}`) + } + + if (!term) { + return ( +
+
+
+ ) + } + + return ( + +
+ +

{term.title}

+
+ +
+
+ {term.content} +
+ + + + +
+
+ ) +} diff --git a/app/merchant/signup/wallet/terms/components/TermModal.tsx b/app/merchant/signup/wallet/terms/components/TermModal.tsx new file mode 100644 index 0000000..af53934 --- /dev/null +++ b/app/merchant/signup/wallet/terms/components/TermModal.tsx @@ -0,0 +1,73 @@ +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import { Term } from "@/app/merchant/signup/wallet/terms/data/walletTerms"; +import {FC} from "react"; + +interface TermModalProps { + term: Term; + onClose: () => void; + onAgree: () => void; +} + +const modalVariants = { + hidden: { opacity: 0, y: 50, scale: 0.95 }, + visible: { opacity: 1, y: 0, scale: 1 }, + exit: { opacity: 0, y: 50, scale: 0.95 }, +}; + +const TermModal: FC = ({ term, onClose, onAgree }) => { + if (!term) return null; + + return ( + + +
+

+ {term.title} +

+ +
+ +
+ {term.content} +
+ +
+ +
+
+
+ ); +}; + +export default TermModal; \ No newline at end of file diff --git a/app/merchant/signup/wallet/terms/components/TermsAgreementPage.tsx b/app/merchant/signup/wallet/terms/components/TermsAgreementPage.tsx new file mode 100644 index 0000000..fb18a42 --- /dev/null +++ b/app/merchant/signup/wallet/terms/components/TermsAgreementPage.tsx @@ -0,0 +1,178 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { motion, AnimatePresence } from "framer-motion" +import { Button } from "@/components/ui/button" +import { ArrowLeft, AlertCircle } from "lucide-react" +import TermsModal from "@/app/merchant/signup/wallet/terms/components/TermModal" +import TermsCardList from "@/app/merchant/signup/wallet/terms/components/TermsCardList" +import { Term } from "@/app/merchant/signup/wallet/terms/data/walletTerms" + +interface Props { + terms: Term[] + title?: string + description?: string +} + +export default function TermsAgreementPage({ terms, title = "약관 동의", description = "안전한 서비스 이용을 위해 아래 약관에 동의해주세요." }: Props) { + const router = useRouter() + const searchParams = useSearchParams() + + const [agreedTerms, setAgreedTerms] = useState([]) + const [allAgreed, setAllAgreed] = useState(false) + const [showAlert, setShowAlert] = useState(false) + const [currentTermIndex, setCurrentTermIndex] = useState(null) + const [viewedTerms, setViewedTerms] = useState([]) + const [isAllTermsFlow, setIsAllTermsFlow] = useState(false) + const initialRenderRef = useRef(true) + + useEffect(() => { + const agreedParam = searchParams.get("agreed") + if (agreedParam) setAgreedTerms(agreedParam.split(",")) + + const viewedParam = searchParams.get("viewed") + if (viewedParam) setViewedTerms(viewedParam.split(",")) + }, [searchParams]) + + useEffect(() => { + if (initialRenderRef.current) { + initialRenderRef.current = false + return + } + setAllAgreed(terms.every(term => agreedTerms.includes(term.id))) + }, [agreedTerms, terms]) + + const handleToggleTerm = (termId: string) => { + if (!viewedTerms.includes(termId)) { + const idx = terms.findIndex(term => term.id === termId) + if (idx !== -1) { + setCurrentTermIndex(idx) + setIsAllTermsFlow(false) + } + return + } + setAgreedTerms(prev => prev.includes(termId) ? prev.filter(id => id !== termId) : [...prev, termId]) + } + + const handleToggleAll = () => { + if (allAgreed) { + setAgreedTerms([]) + } else { + const allViewed = terms.every(term => viewedTerms.includes(term.id)) + if (allViewed) { + setAgreedTerms(terms.map(term => term.id)) + } else { + setIsAllTermsFlow(true) + const firstNotViewed = terms.findIndex(term => !viewedTerms.includes(term.id)) + setCurrentTermIndex(firstNotViewed !== -1 ? firstNotViewed : 0) + } + } + } + + const handleViewTerm = (termId: string) => { + const idx = terms.findIndex(term => term.id === termId) + if (idx !== -1) { + setCurrentTermIndex(idx) + setIsAllTermsFlow(false) + } + } + + const handleSubmit = () => { + const required = terms.filter(term => term.required).map(term => term.id) + const valid = required.every(termId => agreedTerms.includes(termId)) + if (valid) { + router.push("/merchant/signup/wallet/verify") + } else { + setShowAlert(true) + setTimeout(() => setShowAlert(false), 3000) + } + } + + const handleAgreeCurrentTerm = () => { + if (currentTermIndex === null) return + const current = terms[currentTermIndex] + if (!viewedTerms.includes(current.id)) setViewedTerms(prev => [...prev, current.id]) + if (!agreedTerms.includes(current.id)) setAgreedTerms(prev => [...prev, current.id]) + + if (isAllTermsFlow && currentTermIndex < terms.length - 1) { + setCurrentTermIndex(currentTermIndex + 1) + } else { + setCurrentTermIndex(null) + setIsAllTermsFlow(false) + if (isAllTermsFlow) setAgreedTerms(terms.map(term => term.id)) + } + } + + const handleCloseTermModal = () => { + if (currentTermIndex !== null) { + const current = terms[currentTermIndex] + if (!viewedTerms.includes(current.id)) setViewedTerms(prev => [...prev, current.id]) + } + setCurrentTermIndex(null) + setIsAllTermsFlow(false) + } + + return ( + +
+ + + {title} + +
+ +
+
+ +

{title}

+

{description}

+
+ + + {showAlert && ( + + +

모든 필수 약관에 동의해야 진행할 수 있습니다.

+
+ )} +
+ + + + + + +
+
+ + {currentTermIndex !== null && ( + + )} +
+ ) +} diff --git a/app/merchant/signup/wallet/terms/components/TermsCardList.tsx b/app/merchant/signup/wallet/terms/components/TermsCardList.tsx new file mode 100644 index 0000000..d3aebef --- /dev/null +++ b/app/merchant/signup/wallet/terms/components/TermsCardList.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { Check, Eye } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Term } from "@/app/merchant/signup/wallet/terms/data/walletTerms"; +import { FC } from "react"; + +interface TermsCardListProps { + terms: Term[]; + agreedTerms: string[]; + viewedTerms: string[]; + allAgreed: boolean; + onToggleAll: () => void; + onToggleTerm: (termId: string) => void; + onViewTerm: (termId: string) => void; +} + +const TermsCardList: FC = ({ + terms, + agreedTerms, + viewedTerms, + allAgreed, + onToggleAll, + onToggleTerm, + onViewTerm, + }) => { + return ( + + {/* 모두 동의하기 */} +
+ + {allAgreed && } + + + 모든 약관에 동의합니다 + +
+ + {/* 개별 약관 */} + {terms.map((term, index) => ( + +
+ onToggleTerm(term.id)} + variants={{ + checked: { scale: 1 }, + unchecked: { scale: 0.9 } + }} + animate={agreedTerms.includes(term.id) ? "checked" : "unchecked"} + > + {agreedTerms.includes(term.id) && ( + + )} + +
+ + {term.required ? ( + (필수) + ) : ( + (선택) + )} +
+
+ +
+ ))} +
+ ); +}; + +export default TermsCardList; \ No newline at end of file diff --git a/app/merchant/signup/wallet/terms/data/walletTerms.ts b/app/merchant/signup/wallet/terms/data/walletTerms.ts new file mode 100644 index 0000000..38f14f8 --- /dev/null +++ b/app/merchant/signup/wallet/terms/data/walletTerms.ts @@ -0,0 +1,196 @@ +export interface Term { + id: string + title: string + required: boolean + content: string +} + +export const walletTerms: Term[] = [ + { + id: "wallet-service", + title: "전자지갑 서비스 이용약관", + required: true, + content: `제1조 (목적) +이 약관은 Tokkit(이하 "회사"라 합니다)이 제공하는 전자지갑 서비스(이하 "서비스"라 합니다)의 이용과 관련하여 회사와 이용자 간의 권리, 의무 및 책임사항, 기타 필요한 사항을 규정함을 목적으로 합니다. + +제2조 (용어의 정의) +① "전자지갑"이란 이용자가 중앙은행 디지털 화폐(CBDC)를 안전하게 보관하고 이용할 수 있도록 회사가 제공하는 전자적 수단을 말합니다. +② "이용자"란 이 약관에 따라 회사가 제공하는 서비스를 이용하는 자를 말합니다. +③ "CBDC"란 중앙은행이 발행하는 디지털 형태의 법정 화폐를 말합니다. + +제3조 (약관의 효력 및 변경) +① 이 약관은 서비스를 이용하고자 하는 모든 이용자에게 적용됩니다. +② 회사는 관련 법령을 위배하지 않는 범위에서 이 약관을 개정할 수 있습니다. +③ 회사가 약관을 변경할 경우에는 적용일자 및 변경사유를 명시하여 서비스 내에 공지합니다. + +제4조 (서비스의 내용) +① 회사가 제공하는 서비스는 다음 각 호와 같습니다. + 1. CBDC 보관 및 관리 서비스 + 2. CBDC 송금 및 결제 서비스 + 3. 기타 회사가 정하는 서비스 +② 회사는 서비스의 내용을 변경하거나 추가할 수 있으며, 이 경우 회사는 변경 또는 추가 내용을 서비스 내에 공지합니다. + +제5조 (서비스 이용 제한) +① 회사는 다음 각 호에 해당하는 경우 서비스 이용을 제한할 수 있습니다. + 1. 이용자가 이 약관을 위반한 경우 + 2. 이용자가 법령을 위반한 경우 + 3. 이용자가 타인의 명의나 정보를 도용한 경우 + 4. 기타 회사가 정한 사유에 해당하는 경우 +② 서비스 이용이 제한된 경우, 회사는 이용자에게 그 사유 및 기간을 통지합니다. + +제6조 (이용자의 의무) +① 이용자는 서비스 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다. + 1. 타인의 정보를 도용하거나 허위 정보를 제공하는 행위 + 2. 회사의 서비스를 이용하여 불법적인 목적을 달성하고자 하는 행위 + 3. 회사의 서비스를 이용하여 타인에게 피해를 주는 행위 + 4. 기타 관련 법령에 위배되는 행위 + +제7조 (회사의 의무) +① 회사는 이용자가 안전하게 서비스를 이용할 수 있도록 개인정보 보호를 위한 보안 시스템을 갖추어야 합니다. +② 회사는 이용자로부터 제기되는 의견이나 불만이 정당하다고 인정할 경우에는 신속히 처리하여야 합니다. + +제8조 (손해배상) +① 회사는 서비스 제공과 관련하여 회사의 고의 또는 과실로 인해 이용자에게 손해가 발생한 경우, 관련 법령에 따라 손해를 배상합니다. +② 이용자가 이 약관을 위반하여 회사에 손해를 입힌 경우, 이용자는 회사에 대하여 그 손해를 배상하여야 합니다. + +제9조 (분쟁해결) +① 서비스 이용과 관련하여 회사와 이용자 간에 분쟁이 발생한 경우, 양 당사자는 분쟁의 해결을 위해 성실히 협의합니다. +② 제1항의 협의에서 분쟁이 해결되지 않을 경우, 관련 법령에 따라 처리합니다. + +제10조 (약관의 해석) +이 약관에 명시되지 않은 사항은 관련 법령 및 상관례에 따릅니다.`, + }, + { + id: "personal-info", + title: "개인정보 수집 및 이용 동의", + required: true, + content: `개인정보 수집 및 이용 동의 + +Tokkit(이하 "회사"라 합니다)은 전자지갑 서비스 제공을 위해 다음과 같이 개인정보를 수집 및 이용합니다. 내용을 자세히 읽으신 후 동의 여부를 결정하여 주시기 바랍니다. + +1. 수집하는 개인정보 항목 +- 필수항목: 성명, 생년월일, 성별, 휴대전화번호, 이메일 주소, 계좌정보, 주민등록번호(실명확인용) +- 선택항목: 주소, 직업, 소득수준 + +2. 개인정보 수집 및 이용 목적 +- 서비스 제공 및 계약 이행: 전자지갑 서비스 제공, 본인 확인, 거래 내역 관리 +- 서비스 개선: 신규 서비스 개발, 기존 서비스 개선 +- 고객 관리: 고객 문의 응대, 공지사항 전달 +- 법령 준수: 관련 법령에 따른 의무 이행 + +3. 개인정보의 보유 및 이용 기간 +- 회원 탈퇴 시까지 또는 법정 의무 보유기간까지 +- 전자금융거래법에 따라 전자금융거래에 관한 기록은 5년간 보관 +- 통신비밀보호법에 따라 접속 로그는 3개월간 보관 + +4. 동의 거부권 및 거부 시 불이익 +- 필수항목에 대한 동의를 거부할 경우 서비스 이용이 제한됩니다. +- 선택항목에 대한 동의를 거부하더라도 서비스 이용에 제한은 없으나, 일부 서비스 이용이 제한될 수 있습니다. + +5. 개인정보의 제3자 제공 +- 회사는 이용자의 개인정보를 원칙적으로 외부에 제공하지 않습니다. +- 다만, 아래의 경우에는 예외로 합니다. + 1) 이용자가 사전에 동의한 경우 + 2) 법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 요청한 경우 + +6. 개인정보의 안전성 확보 조치 +- 회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취하고 있습니다. + 1) 관리적 조치: 내부관리계획 수립 및 시행, 정기적 직원 교육 + 2) 기술적 조치: 개인정보처리시스템 접근 제한, 암호화 기술 적용, 접속 기록 보관 + 3) 물리적 조치: 전산실, 자료보관실 등의 접근 통제 + +7. 이용자의 권리와 행사 방법 +- 이용자는 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다. +- 권리 행사는 회사에 대해 서면, 전화, 전자우편, 모사전송(FAX) 등을 통하여 하실 수 있으며 회사는 이에 대해 지체 없이 조치하겠습니다. + +8. 개인정보 보호책임자 및 연락처 +- 개인정보 보호책임자: 홍길동 +- 연락처: teamtokkit@gmail.com`, + }, + { + id: "financial-info", + title: "금융정보 제공 동의", + required: true, + content: `금융정보 제공 동의서 + +Tokkit(이하 "회사"라 합니다)은 전자지갑 서비스 제공을 위해 다음과 같이 금융정보를 수집 및 이용합니다. 내용을 자세히 읽으신 후 동의 여부를 결정하여 주시기 바랍니다. + +1. 금융정보 제공 동의 대상 기관 +- 금융결제원, 은행연합회, 신용정보집중기관, 신용정보회사, 금융회사(은행, 카드사 등) + +2. 금융정보 수집 항목 +- 계좌정보: 계좌번호, 은행명, 예금주명 +- 거래정보: 거래내역, 거래금액, 거래일시 +- 신용정보: 신용등급, 연체정보, 대출정보 + +3. 금융정보 수집 및 이용 목적 +- 전자지갑 서비스 제공 및 운영 +- 금융사고 방지 및 조사 +- 법령상 의무 이행 +- 금융거래 내역 관리 및 분석 + +4. 금융정보의 보유 및 이용 기간 +- 회원 탈퇴 시까지 또는 법정 의무 보유기간까지 +- 전자금융거래법에 따라 전자금융거래에 관한 기록은 5년간 보관 +- 금융실명거래 및 비밀보장에 관한 법률에 따른 거래기록은 5년간 보관 + +5. 금융정보 제공 동의의 효력 기간 +- 서비스 이용계약 체결일로부터 서비스 해지일 또는 회원 탈퇴일까지 +- 단, 관련 법령에 따라 보존할 필요가 있는 경우 해당 법령에서 정한 기간까지 + +6. 동의 거부권 및 거부 시 불이익 +- 본 금융정보 제공 동의는 전자지갑 서비스 이용을 위한 필수적 사항으로, 동의를 거부할 경우 서비스 이용이 제한됩니다. + +7. 금융정보의 파기 절차 및 방법 +- 회사는 금융정보 수집 및 이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. +- 전자적 파일 형태로 저장된 금융정보는 복구 불가능한 방법으로 영구 삭제하며, 종이에 출력된 금융정보는 분쇄기로 분쇄하거나 소각하여 파기합니다. + +8. 금융정보의 안전성 확보 조치 +- 회사는 금융정보의 안전성 확보를 위해 다음과 같은 조치를 취하고 있습니다. + 1) 금융정보 접근 권한 제한 및 접근 통제 시스템 구축 + 2) 금융정보 송수신 시 암호화 기술 적용 + 3) 금융정보 처리 시스템에 대한 접속 기록 보관 및 위변조 방지 조치 + 4) 금융정보 보호를 위한 내부관리계획 수립 및 시행 + +9. 금융정보 보호책임자 및 연락처 +- 금융정보 보호책임자: 김철수 +- 연락처: teamtokkit@gmail.com`, + }, + { + id: "marketing", + title: "마케팅 정보 수신 동의", + required: false, + content: `마케팅 정보 수신 동의 + +Tokkit(이하 "회사"라 합니다)은 이용자에게 유용한 서비스 및 이벤트 정보를 제공하기 위해 다음과 같이 마케팅 정보를 수신하는 것에 대한 동의를 받고 있습니다. 내용을 자세히 읽으신 후 동의 여부를 결정하여 주시기 바랍니다. + +1. 마케팅 정보 수신 동의 내용 +- 회사가 제공하는 서비스, 이벤트, 프로모션 등의 광고성 정보 + +2. 마케팅 정보 수신 방법 +- 이메일, SMS, 푸시 알림, 우편 등 + +3. 마케팅 정보 수신 목적 +- 신규 서비스 및 이벤트 안내 +- 맞춤형 혜택 및 프로모션 제공 +- 서비스 개선을 위한 의견 수렴 + +4. 마케팅 정보 수신 동의의 효력 기간 +- 회원 탈퇴 시 또는 마케팅 정보 수신 동의 철회 시까지 + +5. 동의 거부권 및 거부 시 불이익 +- 본 마케팅 정보 수신 동의는 선택사항으로, 동의를 거부하더라도 기본적인 서비스 이용에는 제한이 없습니다. +- 다만, 동의하지 않을 경우 회사가 제공하는 각종 혜택 및 이벤트 정보를 받아보실 수 없습니다. + +6. 마케팅 정보 수신 동의 철회 방법 +- 회원은 언제든지 마케팅 정보 수신 동의를 철회할 수 있습니다. +- 철회 방법: 서비스 내 '설정 > 알림 설정'에서 변경 또는 고객센터(1588-1234)로 요청 + +7. 개인정보의 제3자 제공 및 위탁 +- 회사는 마케팅 활동을 위해 필요한 경우 이용자의 개인정보를 외부 전문업체에 위탁할 수 있습니다. +- 위탁 업체 및 위탁 업무 내용은 회사 홈페이지의 '개인정보처리방침'에서 확인하실 수 있습니다. + +8. 마케팅 정보 관련 문의 +- 마케팅 정보 수신과 관련하여 문의사항이 있으신 경우, 이메일(teamtokkit@gmail.com)로 문의해 주시기 바랍니다.` + } +] diff --git a/app/merchant/signup/wallet/terms/page.tsx b/app/merchant/signup/wallet/terms/page.tsx new file mode 100644 index 0000000..d8bc5a0 --- /dev/null +++ b/app/merchant/signup/wallet/terms/page.tsx @@ -0,0 +1,40 @@ +import { Suspense } from "react" +import { walletTerms } from "@/app/merchant/signup/wallet/terms/data/walletTerms" +import TermsAgreementPage from "@/app/merchant/signup/wallet/terms/components/TermsAgreementPage" + +function TermsAgreementFallback() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+ ) +} + +export default function WalletTermsPage() { + return ( + }> + + + ) +} diff --git a/app/merchant/signup/wallet/verify/api/ocr.ts b/app/merchant/signup/wallet/verify/api/ocr.ts new file mode 100644 index 0000000..fab52d3 --- /dev/null +++ b/app/merchant/signup/wallet/verify/api/ocr.ts @@ -0,0 +1,30 @@ +import { getApiUrl } from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export interface OcrResponse { + name: string; + residentId: string; + issueDate: string; +} + +export async function extractIdCardInfo(imageFile: File): Promise { + const formData = new FormData(); + formData.append("image", imageFile); + + const res = await fetch(`${API_URL}/api/ocr/idCard`, { + method: "POST", + body: formData, + }); + + if (!res.ok) { + throw new Error("OCR 요청 실패"); + } + + const json = await res.json(); + return { + name: json.result.name, + residentId: json.result.rrnFront + json.result.rrnBack, + issueDate: json.result.issuedDate, + }; +} diff --git a/app/merchant/signup/wallet/verify/components/CaptureStep.tsx b/app/merchant/signup/wallet/verify/components/CaptureStep.tsx new file mode 100644 index 0000000..6b913b3 --- /dev/null +++ b/app/merchant/signup/wallet/verify/components/CaptureStep.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ShieldCheck, Camera, Upload, X, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { extractIdCardInfo } from "@/app/merchant/signup/wallet/verify/api/ocr"; +import Image from "next/image"; + +interface CaptureStepProps { + onNext: () => void; + onRetry: () => void; + onBack: () => void; + setCapturedData: (data: { + name: string; + residentIdFront: string; + residentIdBack: string; + issueDate: string; + }) => void; + selectedMethod: "camera" | "upload" | null; + setSelectedMethod: (method: "camera" | "upload") => void; +} + +export default function CaptureStep({ + onNext, + onRetry, + onBack, + setCapturedData, + selectedMethod, + setSelectedMethod, + }: CaptureStepProps) { + const fileInputRef = useRef(null); + const videoRef = useRef(null); + const canvasRef = useRef(null); + const [showCamera, setShowCamera] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [capturedImage, setCapturedImage] = useState(null); + + useEffect(() => { + if (showCamera) { + navigator.mediaDevices + .getUserMedia({ video: { facingMode: "environment" } }) + .then((stream) => { + if (videoRef.current) { + videoRef.current.srcObject = stream; + } + }) + .catch(() => alert("카메라 접근 실패")); + } + + return () => { + if (videoRef.current?.srcObject) { + const tracks = (videoRef.current.srcObject as MediaStream).getTracks(); + tracks.forEach((track) => track.stop()); + } + }; + }, [showCamera]); + + const handleCapture = () => { + if (!videoRef.current || !canvasRef.current) return; + const video = videoRef.current; + const canvas = canvasRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const imgUrl = canvas.toDataURL("image/jpeg"); + setCapturedImage(imgUrl); + setShowCamera(false); + processOcr(imgUrl); + }; + + const processOcr = async (base64Image: string) => { + try { + setIsProcessing(true); + const file = await fetch(base64Image) + .then((res) => res.blob()) + .then( + (blob) => new File([blob], "capture.jpg", { type: "image/jpeg" }) + ); + + const data = await extractIdCardInfo(file); + const rrnRaw = data.residentId ?? ""; + const residentIdFront = rrnRaw.slice(0, 6); + const residentIdBack = rrnRaw[6] ? rrnRaw[6] + "******" : "*".repeat(7); + + setCapturedData({ + name: data.name ?? "", + residentIdFront, + residentIdBack, + issueDate: (data.issueDate ?? "").replaceAll(".", ""), + }); + + onNext(); + } catch (e) { + alert("OCR 인식 실패: " + (e as Error).message); + onRetry(); + } finally { + setIsProcessing(false); + } + }; + + const handleUpload = (file: File) => { + const reader = new FileReader(); + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + processOcr(dataUrl); + }; + reader.readAsDataURL(file); + }; + + return ( +
+
+
+

신분증 인식

+

+ 주민등록증을 촬영하거나 이미지를 업로드하세요. +
+ 신분증의 모든 정보가 선명하게 보이도록 해주세요. +

+
+ +
+ + + +
+
+ + {showCamera && ( +
+
+
+

신분증 촬영

+ +
+ +
+
+ +
+ + +
+
+
+ )} + + {isProcessing && ( +
+
+ +

OCR 인식 중...

+
+
+ )} +
+ ); +} diff --git a/app/merchant/signup/wallet/verify/components/ReviewStep.tsx b/app/merchant/signup/wallet/verify/components/ReviewStep.tsx new file mode 100644 index 0000000..bd7c995 --- /dev/null +++ b/app/merchant/signup/wallet/verify/components/ReviewStep.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { FC, RefObject } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; + +interface ReviewStepProps { + name: string; + residentIdFront: string; + residentIdBack: string; + issueDate: string; + onChangeName: (value: string) => void; + onChangeResidentIdFront: (value: string) => void; + onChangeResidentIdBack: (value: string) => void; + onChangeIssueDate: (value: string) => void; + onRetryCapture: () => void; + onSubmit: () => void; + isLoading: boolean; + residentIdBackRef: RefObject; + issueDateRef: RefObject; + success: string | null; + error: string | null; +} + +const ReviewStep: FC = ({ + name, + residentIdFront, + residentIdBack, + issueDate, + onChangeName, + onChangeResidentIdFront, + onChangeResidentIdBack, + onChangeIssueDate, + onRetryCapture, + onSubmit, + isLoading, + residentIdBackRef, + issueDateRef, + success, + error, + }) => { + return ( +
+
+

인식 결과 확인

+

+ 신분증에서 인식된 정보를 확인하고 필요한 경우 수정해주세요. +

+
+ +
+
+ + onChangeName(e.target.value)} + required + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + /> +
+ +
+ +
+ onChangeResidentIdFront(e.target.value)} + required + maxLength={6} + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + /> + - + onChangeResidentIdBack(e.target.value)} + required + maxLength={7} + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + ref={residentIdBackRef} + /> +
+
+ +
+ + onChangeIssueDate(e.target.value)} + required + maxLength={8} + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + ref={issueDateRef} + /> +

+ 주민등록증에 기재된 발급일자 8자리를 입력하세요. +

+
+
+ +
+ + +
+ + {success && ( +
+ {success} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ ); +}; + +export default ReviewStep; diff --git a/app/merchant/signup/wallet/verify/page.tsx b/app/merchant/signup/wallet/verify/page.tsx new file mode 100644 index 0000000..f832060 --- /dev/null +++ b/app/merchant/signup/wallet/verify/page.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { motion } from "framer-motion"; +import { ArrowLeft, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import CaptureStep from "@/app/merchant/signup/wallet/verify/components/CaptureStep"; +import ReviewStep from "@/app/merchant/signup/wallet/verify/components/ReviewStep"; + +export type VerificationStep = "input" | "capture" | "review"; + +export default function WalletVerifyPage() { + const router = useRouter(); + const [step, setStep] = useState("capture"); + + // 사용자 입력 상태 + const [name, setName] = useState(""); + const [residentIdFront, setResidentIdFront] = useState(""); + const [residentIdBack, setResidentIdBack] = useState(""); + const [issueDate, setIssueDate] = useState(""); + + // OCR 데이터 수신 여부 체크 + const [isCaptured, setIsCaptured] = useState(false); + + // 선택된 업로드 방식 상태 + const [selectedMethod, setSelectedMethod] = useState<"camera" | "upload" | null>(null); + + // UI 상태 + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // ref (자동 포커스 이동용) + const residentIdBackRef = useRef(null!); + const issueDateRef = useRef(null!); + + const handleFinalSubmit = async () => { + setIsLoading(true); + setError(null); + try { + await new Promise((res) => setTimeout(res, 1000)); + setSuccess("본인인증이 완료되었습니다."); + sessionStorage.setItem( + "verifiedName", + name // OCR로 추출한 이름 + ); + setTimeout(() => { + router.push("/merchant/signup/wallet/contact"); + }, 1200); + } catch (e) { + setError("본인인증에 실패했습니다. 다시 시도해주세요."); + } finally { + setIsLoading(false); + } + }; + + return ( + + {/* 헤더 */} +
+ + + 본인인증 + +
+ +
+
+
+ +

본인인증

+

+ 전자지갑 개설을 위해 본인인증이 필요합니다. +
정확한 정보를 입력해주세요. +

+
+ + {step === "capture" && ( + setStep("review")} + onRetry={() => setStep("capture")} + onBack={() => router.back()} + selectedMethod={selectedMethod} + setSelectedMethod={setSelectedMethod} + setCapturedData={(data: { name: string; residentIdFront: string; residentIdBack: string; issueDate: string }) => { + try { + setName(data.name ?? ""); + setResidentIdFront(data.residentIdFront ?? ""); + setResidentIdBack(data.residentIdBack ?? ""); + setIssueDate((data.issueDate ?? "").replaceAll(".", "")); + setIsCaptured(true); + } catch (e) { + alert("OCR 인식 실패: " + (e as Error).message); + setStep("capture"); + } + }} + /> + )} + + {step === "review" && ( + { + setName(val); + setIsCaptured(true); + }} + onChangeResidentIdFront={(val) => { + setResidentIdFront(val); + setIsCaptured(true); + }} + onChangeResidentIdBack={(val) => { + setResidentIdBack(val); + setIsCaptured(true); + }} + onChangeIssueDate={(val) => { + setIssueDate(val); + setIsCaptured(true); + }} + onRetryCapture={() => setStep("capture")} + onSubmit={handleFinalSubmit} + isLoading={isLoading} + residentIdBackRef={residentIdBackRef} + issueDateRef={issueDateRef} + success={success} + error={error} + /> + )} +
+
+
+ ); +} diff --git a/app/merchant/vouchers/api/voucher.ts b/app/merchant/vouchers/api/voucher.ts new file mode 100644 index 0000000..f0b561c --- /dev/null +++ b/app/merchant/vouchers/api/voucher.ts @@ -0,0 +1,35 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import { getCookie } from "@/lib/cookies"; +import { fetchWithAuth } from "@/lib/fetchWithAuth"; +import { VoucherSearchParams } from "@/app/merchant/vouchers/types/voucher"; + +const API_URL = getApiUrl(); + +// 전체 바우처 조회 및 검색 API +export async function getVouchers({ keyword = "", page = 0, size = 10 }: VoucherSearchParams) { + + const token = getCookie("accessToken"); + if (!token) throw new Error("accessToken 없음"); + + const queryParams = new URLSearchParams(); + if (keyword) queryParams.append("keyword", keyword); + queryParams.append("page", String(page)); + queryParams.append("size", String(size)); + + const res = await fetchWithAuth(`${API_URL}/api/merchants/vouchers?${queryParams}`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + credentials: "include", + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`바우처 조회 실패: ${res.status} - ${errText}`); + } + + const data = await res.json(); + return data.result; +} + diff --git a/app/merchant/vouchers/components/Header.tsx b/app/merchant/vouchers/components/Header.tsx new file mode 100644 index 0000000..cb77880 --- /dev/null +++ b/app/merchant/vouchers/components/Header.tsx @@ -0,0 +1,18 @@ +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Search, ArrowLeft } from 'lucide-react' +import { Button } from '@/components/ui/button' + +export default function VouchersHeader() { + const router = useRouter() + return ( +
+
+ +

바우처 조회

+
+
+ ) +} \ No newline at end of file diff --git a/app/merchant/vouchers/components/SearchBar.tsx b/app/merchant/vouchers/components/SearchBar.tsx new file mode 100644 index 0000000..e2e8896 --- /dev/null +++ b/app/merchant/vouchers/components/SearchBar.tsx @@ -0,0 +1,23 @@ +import { Search } from "lucide-react" +import { Input } from "@/components/ui/input" + +interface Props { + value: string + onChange: (value: string) => void +} + +export default function VoucherSearchBar({ value, onChange }: Props) { + return ( +
+
+ + onChange(e.target.value)} + placeholder="바우처 검색" + className="pl-10 pr-4 py-3 rounded-lg" + /> +
+
+ ) +} diff --git a/app/merchant/vouchers/components/VoucherCard.tsx b/app/merchant/vouchers/components/VoucherCard.tsx new file mode 100644 index 0000000..cc8b0aa --- /dev/null +++ b/app/merchant/vouchers/components/VoucherCard.tsx @@ -0,0 +1,58 @@ +import Image from "next/image" +import { motion } from "framer-motion" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Voucher } from "@/app/vouchers/types/voucher" + +interface Props { + voucher: Voucher +} + +export default function VoucherCard({ voucher}: Props) { + const router = useRouter() + + + return ( + +
+ {voucher.name} +
+ +
+

{voucher.name}

+

{voucher.description}

+ +
+
+

유효기간

+

+ {new Date(voucher.validDate).toLocaleDateString().replace(/\.$/, "")} +

+
+
+
+

+ {voucher.price.toLocaleString()}원 + 토큰가 +

+ {voucher.originalPrice && ( +

+ {voucher.originalPrice.toLocaleString()}원 +

+ )} +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/merchant/vouchers/components/VoucherList.tsx b/app/merchant/vouchers/components/VoucherList.tsx new file mode 100644 index 0000000..76b1958 --- /dev/null +++ b/app/merchant/vouchers/components/VoucherList.tsx @@ -0,0 +1,99 @@ +'use client' + +import VoucherCard from "@/app/merchant/vouchers/components/VoucherCard" +import { getVouchers } from "@/app/merchant/vouchers/api/voucher" +import { useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import Pagination from "@/components/common/Pagination" +import { Voucher } from "@/app/vouchers/types/voucher" + +interface Props { + keyword: string +} + +export default function VoucherList({ keyword }: Props) { + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [vouchers, setVouchers] = useState([]) + const searchParams = useSearchParams() + + useEffect(() => { + const debounceTimer = setTimeout(() => { + setLoading(true) + + getVouchers({ + keyword, + page: page - 1, + size: 10, + }) + .then((data) => { + setVouchers(data.content) + setTotalPages(data.totalPages) + }) + .catch((error) => { + console.error("Error fetching vouchers:", error) + }) + .finally(() => { + setLoading(false) + }) + }, 600) + return () => clearTimeout(debounceTimer) + }, [keyword, page]) + + + + if (loading) { + return ( +
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) + } + + if (vouchers.length === 0) { + return ( +
+

검색 결과가 없습니다.

+
+ ) + } + + + return ( +
+
+ {vouchers.map((voucher) => ( + + ))} +
+ + +
+ ) +} \ No newline at end of file diff --git a/app/merchant/vouchers/page.tsx b/app/merchant/vouchers/page.tsx new file mode 100644 index 0000000..b0b618c --- /dev/null +++ b/app/merchant/vouchers/page.tsx @@ -0,0 +1,21 @@ +// app/merchant/vouchers/page.tsx +'use client' + +import { Suspense,useState } from "react" +import VouchersHeader from "@/app/merchant/vouchers/components/Header" +import VoucherSearchBar from "@/app/merchant/vouchers/components/SearchBar" +import VoucherList from "@/app/merchant/vouchers/components/VoucherList" + +export default function MerchantVouchersPage() { + const [searchQuery, setSearchQuery] = useState("") + + return ( +
+ + + 바우처 목록 불러오는 중...
}> + + +
+ ) +} diff --git a/app/merchant/vouchers/types/voucher.ts b/app/merchant/vouchers/types/voucher.ts new file mode 100644 index 0000000..f8e982e --- /dev/null +++ b/app/merchant/vouchers/types/voucher.ts @@ -0,0 +1,5 @@ +export interface VoucherSearchParams { + keyword?: string; + page?: number; + size?: number; +} \ No newline at end of file diff --git a/app/merchant/wallet/components/ConvertButton.tsx b/app/merchant/wallet/components/ConvertButton.tsx new file mode 100644 index 0000000..055401d --- /dev/null +++ b/app/merchant/wallet/components/ConvertButton.tsx @@ -0,0 +1,22 @@ +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; +import { MoveRight } from "lucide-react"; + +export default function ConvertButton() { + const router = useRouter(); + + return ( +
+ +
+ ); +} diff --git a/app/merchant/wallet/components/WalletCard.tsx b/app/merchant/wallet/components/WalletCard.tsx new file mode 100644 index 0000000..0c34932 --- /dev/null +++ b/app/merchant/wallet/components/WalletCard.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Wallet, ArrowRight } from "lucide-react"; +import { Coins, Banknote } from "lucide-react"; + +interface WalletCardProps { + tokenBalance: number; + depositBalance: number; + storeName: string; + accountNumber: string; +} + +export default function WalletCard({ + tokenBalance, + depositBalance, + storeName, + accountNumber +}: WalletCardProps) { + return ( + +
+
+
+ +
+
+
+ +
+
+

+ {storeName} 님의 지갑 +

+

{accountNumber}

+
+
+ +
+
+
+
+ +

토큰 잔액

+
+
+
+ {tokenBalance.toLocaleString()} + TKT +
+
+ +
+
+
+ +

예금 잔액

+
+
+
+ {depositBalance.toLocaleString()} + +
+
+
+
+
+ ); +} diff --git a/app/merchant/wallet/components/WalletGuide.tsx b/app/merchant/wallet/components/WalletGuide.tsx new file mode 100644 index 0000000..3d777e4 --- /dev/null +++ b/app/merchant/wallet/components/WalletGuide.tsx @@ -0,0 +1,23 @@ +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Info, ChevronRight } from "lucide-react"; + +export default function WalletGuide() { + const router = useRouter(); + + return ( +
+ +
+ ); +} diff --git a/app/merchant/wallet/convert/api/token-to-deposit.ts b/app/merchant/wallet/convert/api/token-to-deposit.ts new file mode 100644 index 0000000..8ad001a --- /dev/null +++ b/app/merchant/wallet/convert/api/token-to-deposit.ts @@ -0,0 +1,18 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export async function tokenToDeposit(amount: number, simplePassword: string) { + const res = await fetchWithAuth(`${API_URL}/api/merchants/wallet/convert/token-to-deposit`, { + method: "POST", + credentials: "include", + body: JSON.stringify({ amount, simplePassword }), + }); + + const data = await res.json(); + + if (!data.isSuccess) throw new Error(data.message || "전환 실패"); + + return data.result; +} diff --git a/app/merchant/wallet/convert/api/verify-merchant-simple-password.ts b/app/merchant/wallet/convert/api/verify-merchant-simple-password.ts new file mode 100644 index 0000000..25be448 --- /dev/null +++ b/app/merchant/wallet/convert/api/verify-merchant-simple-password.ts @@ -0,0 +1,21 @@ +import {getApiUrl} from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export async function verifyMerchantSimplePassword(simplePassword: string) { + const res = await fetchWithAuth(`${API_URL}/api/merchants/simple-password/verify`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ simplePassword }), + }); + + const data = await res.json(); + + if (!data.isSuccess) return false; + + return true; +} diff --git a/app/merchant/wallet/convert/components/AmountStep.tsx b/app/merchant/wallet/convert/components/AmountStep.tsx new file mode 100644 index 0000000..a624df2 --- /dev/null +++ b/app/merchant/wallet/convert/components/AmountStep.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import BalanceCard from "@/app/wallet/components/convert/BalanceCard"; +import AmountInput from "@/components/common/AmountInput"; +import InfoBox from "@/app/wallet/components/common/InfoBox"; +import { useMemo } from "react"; + +interface AmountStepProps { + type: "deposit-to-token" | "token-to-deposit"; + amount: string; + depositBalance: number; + tokenBalance: number; + setAmount: React.Dispatch>; + onMax: () => void; + onChange: (val: string) => void; + onContinue: () => void; +} + +export default function AmountStep({ + type, + amount, + depositBalance, + tokenBalance, + setAmount, + onMax, + onChange, + onContinue, + }: AmountStepProps) { + const isDepositToToken = type === "deposit-to-token"; + const currentBalance = isDepositToToken ? depositBalance : tokenBalance; + + const infoText = isDepositToToken + ? "토큰을 예금으로 전환하면 즉시 반영됩니다. 예금은 언제든지 다시 토큰으로 전환할 수 있습니다." + : "예금을 토큰으로 전환하면 즉시 반영됩니다. 토큰은 결제, 송금 등에 사용할 수 있으며, 언제든지 다시 예금으로 전환할 수 있습니다."; + + const handleMaxAmount = () => { + const max = currentBalance; + setAmount(String(max)); + }; + + // 초과 여부 확인 + const isExceedingBalance = useMemo(() => { + const numericAmount = Number(amount); + return !isNaN(numericAmount) && numericAmount > currentBalance; + }, [amount, currentBalance]); + + const isDisabled = + !amount || Number(amount) <= 0 || isNaN(Number(amount)) || isExceedingBalance; + + return ( +
+ +
+ +
+ + + 잔액을 초과했습니다 + + ) + } + /> + +
+ {infoText} +
+ +
+ +
+
+
+ ); +} diff --git a/app/merchant/wallet/convert/components/BalanceCard.tsx b/app/merchant/wallet/convert/components/BalanceCard.tsx new file mode 100644 index 0000000..5f3ba58 --- /dev/null +++ b/app/merchant/wallet/convert/components/BalanceCard.tsx @@ -0,0 +1,44 @@ +interface BalanceCardProps { + type: "deposit-to-token" | "token-to-deposit"; + depositBalance: number; + tokenBalance: number; +} + +export default function BalanceCard({ + type, + depositBalance, + tokenBalance, +}: BalanceCardProps) { + const isDepositToToken = type === "deposit-to-token"; + + const primaryStyle = "text-base font-semibold text-[#1A1A1A]"; + const secondaryStyle = "text-base text-[#666666]"; + + return ( +
+ {isDepositToToken ? ( + <> +
+

예금 잔액

+

{depositBalance.toLocaleString()}원

+
+
+

토큰 잔액

+

{tokenBalance.toLocaleString()}원

+
+ + ) : ( + <> +
+

토큰 잔액

+

{tokenBalance.toLocaleString()}원

+
+
+

예금 잔액

+

{depositBalance.toLocaleString()}원

+
+ + )} +
+ ); +} diff --git a/app/merchant/wallet/convert/components/CompleteStep.tsx b/app/merchant/wallet/convert/components/CompleteStep.tsx new file mode 100644 index 0000000..e4b5c58 --- /dev/null +++ b/app/merchant/wallet/convert/components/CompleteStep.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import { LoaderCircle, CheckCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import BalanceCard from "@/app/wallet/components/convert/BalanceCard"; +import confetti from "canvas-confetti"; + +interface CompleteStepProps { + type: "deposit-to-token" | "token-to-deposit"; + amount: string; + depositBalance: number; + tokenBalance: number; + onBackToWallet: () => void; +} + +export default function CompleteStep({ + type, + amount, + depositBalance, + tokenBalance, + onBackToWallet, +}: CompleteStepProps) { + const [done, setDone] = useState(false); + const parsedAmount = Number(amount); + const isDepositToToken = type === "deposit-to-token"; + + useEffect(() => { + if (!done) return; + + const duration = 3 * 1000; + const animationEnd = Date.now() + duration; + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 }; + + function randomInRange(min: number, max: number) { + return Math.random() * (max - min) + min; + } + + const interval = setInterval(() => { + const timeLeft = animationEnd - Date.now(); + if (timeLeft <= 0) return clearInterval(interval); + + const particleCount = 50 * (timeLeft / duration); + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, + }); + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, + }); + }, 450); + + return () => clearInterval(interval); + }, [done]); + + return ( + + {!done ? ( + setDone(true)} + className="mb-6" + > + + + ) : ( + + + + )} + +

+ 변환 완료 +

+

+ {parsedAmount.toLocaleString()}원이{" "} + {isDepositToToken ? "토큰으로" : "예금으로"} 변환되었습니다. +

+ +
+ +
+ + +
+ ); +} diff --git a/app/merchant/wallet/convert/components/ConfirmStep.tsx b/app/merchant/wallet/convert/components/ConfirmStep.tsx new file mode 100644 index 0000000..f79f015 --- /dev/null +++ b/app/merchant/wallet/convert/components/ConfirmStep.tsx @@ -0,0 +1,63 @@ +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import ConvertPreview from "@/app/wallet/components/convert/ConvertPreview"; +import InfoBox from "@/app/wallet/components/common/InfoBox"; + +interface ConfirmStepProps { + type: "deposit-to-token" | "token-to-deposit"; + amount: string; + depositBalance: number; + tokenBalance: number; + onBack: () => void; + onConfirm: () => void; +} + +export default function ConfirmStep({ + type, + amount, + depositBalance, + tokenBalance, + onBack, + onConfirm, +}: ConfirmStepProps) { + return ( + +
+
+
+ +
+ + + 전환 후에는 취소할 수 없으며, 다시 토큰으로 전환하려면 별도의 절차가 + 필요합니다. + +
+ +
+ + +
+
+
+ ); +} diff --git a/app/merchant/wallet/convert/components/ConvertPreview.tsx b/app/merchant/wallet/convert/components/ConvertPreview.tsx new file mode 100644 index 0000000..4a92142 --- /dev/null +++ b/app/merchant/wallet/convert/components/ConvertPreview.tsx @@ -0,0 +1,107 @@ +import Image from "next/image"; + +interface ConvertPreviewProps { + type: "deposit-to-token" | "token-to-deposit"; + amount: string; + depositBalance: number; + tokenBalance: number; +} + +export default function ConvertPreview({ + type, + amount, + depositBalance, + tokenBalance, +}: ConvertPreviewProps) { + const parsedAmount = Number.parseInt(amount); + const isDepositToToken = type === "deposit-to-token"; + + const labelFrom = isDepositToToken ? "예금에서" : "토큰에서"; + const labelTo = isDepositToToken ? "토큰으로" : "예금으로"; + const imageSrc = "/images/arrow-down.gif"; + + // 계산된 전환 후 잔액 + const finalDeposit = isDepositToToken + ? depositBalance - parsedAmount // 예금 → 토큰 시 예금에서 차감 + : depositBalance + parsedAmount; // 토큰 → 예금 시 예금에 더함 + + const finalToken = isDepositToToken + ? tokenBalance + parsedAmount // 예금 → 토큰 시 토큰에 더함 + : tokenBalance - parsedAmount; // 토큰 → 예금 시 토큰에서 차감 + + return ( +
+

+ 전환 정보 확인 +

+ +
+
+

{labelFrom}

+

+ {" "} + -{parsedAmount.toLocaleString()}원 +

+
+ + 전환 이미지 + +
+

{labelTo}

+

+ {" "} + +{parsedAmount.toLocaleString()}원 +

+
+
+ +
+ {isDepositToToken ? ( + <> +
+

전환 후 예금 잔액

+

+ {finalDeposit.toLocaleString()}원 +

+
+
+

전환 후 토큰 잔액

+

+ {finalToken.toLocaleString()}원 +

+
+ + ) : ( + <> +
+

전환 후 토큰 잔액

+

+ {finalToken.toLocaleString()}원 +

+
+
+

전환 후 예금 잔액

+

+ {finalDeposit.toLocaleString()}원 +

+
+ + )} +
+
+ ); +} diff --git a/app/merchant/wallet/convert/components/ProcessingStep.tsx b/app/merchant/wallet/convert/components/ProcessingStep.tsx new file mode 100644 index 0000000..eeb5a01 --- /dev/null +++ b/app/merchant/wallet/convert/components/ProcessingStep.tsx @@ -0,0 +1,40 @@ +import { motion } from "framer-motion"; +import Image from "next/image"; + +interface ProcessingStepProps { + type: "deposit-to-token" | "token-to-deposit"; +} + +export default function ProcessingStep({ type }: ProcessingStepProps) { + const isDepositToToken = type === "deposit-to-token"; + + const imageSrc = isDepositToToken + ? "/images/deposit-to-token.gif" + : "/images/token-to-deposit.gif"; + + const altText = isDepositToToken + ? "예금을 토큰으로 전환" + : "토큰을 예금으로 전환"; + + return ( + +
+
+ {altText} +
+

전환중입니다.

+

잠시만 기다려주세요...

+
+
+ ); +} diff --git a/app/merchant/wallet/convert/components/VerifySimplePassword.tsx b/app/merchant/wallet/convert/components/VerifySimplePassword.tsx new file mode 100644 index 0000000..49059a8 --- /dev/null +++ b/app/merchant/wallet/convert/components/VerifySimplePassword.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import VirtualKeypad from "@/components/virtual-keypad"; + +interface VirtualKeypadProps { + onComplete: (pinCode: string) => Promise; + maxLength: number; + disabled?: boolean; +} + +interface Props { + onVerified: (password: string) => void; +} + +export default function VerifySimplePassword({ onVerified }: Props) { + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleKeypadComplete = async (pinCode: string) => { + setIsLoading(true); + setError(null); + + try { + setPassword(pinCode); + onVerified(pinCode); + } finally { + setIsLoading(false); + } + }; + + const reset = () => { + setPassword(""); + setError(null); + }; + + return ( +
+ +

간편 비밀번호 입력

+
+ + + + + {error && ( + + +

{error}

+
+ )} +
+ + {error && ( + + + + )} +
+ ); +} diff --git a/app/merchant/wallet/convert/page.tsx b/app/merchant/wallet/convert/page.tsx new file mode 100644 index 0000000..2250837 --- /dev/null +++ b/app/merchant/wallet/convert/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState } from "react"; +import {useParams, useRouter, useSearchParams} from "next/navigation"; +import Header from "@/components/common/Header"; +import {fetchMerchantWalletInfo} from "@/app/merchant/dashboard/api/merchant-wallet-info"; +import {verifyMerchantSimplePassword} from "@/app/merchant/wallet/convert/api/verify-merchant-simple-password"; +import {tokenToDeposit} from "@/app/merchant/wallet/convert/api/token-to-deposit"; +import AmountStep from "./components/AmountStep"; +import ConfirmStep from "@/app/merchant/wallet/convert/components/ConfirmStep"; +import VerifySimplePassword from "@/app/merchant/wallet/convert/components/VerifySimplePassword"; +import ProcessingStep from "@/app/merchant/wallet/convert/components/ProcessingStep"; +import CompleteStep from "@/app/merchant/wallet/convert/components/CompleteStep"; +import LoadingOverlay from "@/components/common/LoadingOverlay"; + +export default function ConvertPage() { + const router = useRouter(); + const params = useParams(); + const type = params.type as "token-to-deposit"; + + const [step, setStep] = useState<"amount" | "confirm" | "password" | "processing" | "complete">("amount"); + const [amount, setAmount] = useState(""); + const [depositBalance, setDepositBalance] = useState(0); + const [tokenBalance, setTokenBalance] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + const title = "토큰 → 예금"; + + useEffect(() => { + fetchBalance() + }, []); + + const fetchBalance = async () => { + try { + const data = await fetchMerchantWalletInfo(); + setDepositBalance(data.depositBalance); + setTokenBalance(data.tokenBalance); + } catch (err) { + alert("지갑 정보를 불러오지 못했습니다."); + } + }; + + const handleMax = () => { + const max = tokenBalance; + setAmount(String(max)); + }; + + const handlePasswordComplete = async (simplePassword: string) => { + const amountNum = Number(amount); + setIsLoading(true); + await new Promise(resolve => setTimeout(resolve, 0)); + + try { + await tokenToDeposit(amountNum, simplePassword); + await fetchBalance(); + + setStep("processing"); + setTimeout(() => setStep("complete"), 5000); + } catch (err: any) { + alert(err.message); + setStep("amount"); + } finally { + setIsLoading(false); + } + }; + + + return ( +
+ {isLoading && } + +
+
+ {step === "amount" && ( + setStep("confirm")} + /> + )} + + {step === "confirm" && ( + setStep("amount")} + onConfirm={() => setStep("password")} + /> + )} + + {step === "password" && ( +
+ 간편 비밀번호 입력 + +
+ )} + + + {step === "processing" && } + + {step === "complete" && ( + router.push("/merchant/wallet")} + /> + )} +
+
+ ); +} diff --git a/app/merchant/wallet/guide/components/GuideIntroCard.tsx b/app/merchant/wallet/guide/components/GuideIntroCard.tsx new file mode 100644 index 0000000..de204ce --- /dev/null +++ b/app/merchant/wallet/guide/components/GuideIntroCard.tsx @@ -0,0 +1,21 @@ +import { Wallet } from "lucide-react"; + +export default function WalletIntroCard() { + return ( +
+
+
+ +
+

+ 전자지갑 소개 +

+
+

+ Tokkit 전자지갑은 중앙은행 디지털 화폐(CBDC)를 안전하게 보관하고 사용할 + 수 있는 서비스입니다. 예금을 토큰으로 전환하여 결제, 송금 등 다양한 금융 + 활동을 할 수 있습니다. +

+
+ ); +} diff --git a/app/merchant/wallet/guide/components/GuideList.tsx b/app/merchant/wallet/guide/components/GuideList.tsx new file mode 100644 index 0000000..7c68c91 --- /dev/null +++ b/app/merchant/wallet/guide/components/GuideList.tsx @@ -0,0 +1,37 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { guideItems } from "@/data/wallet/merchantGuideContents"; + +export default function GuideAccordion() { + return ( + + {guideItems.map(({ value, icon: Icon, title, content }) => ( + + +
+
+ +
+ {title} +
+
+ +
"), + }} + /> + + + ))} + + ); +} diff --git a/app/merchant/wallet/guide/components/InfoBox.tsx b/app/merchant/wallet/guide/components/InfoBox.tsx new file mode 100644 index 0000000..1c5682a --- /dev/null +++ b/app/merchant/wallet/guide/components/InfoBox.tsx @@ -0,0 +1,16 @@ +import { AlertCircle } from "lucide-react"; + +interface InfoBoxProps { + children: React.ReactNode; +} + +export default function InfoBox({ children }: InfoBoxProps) { + return ( +
+
+ +
{children}
+
+
+ ); +} diff --git a/app/merchant/wallet/guide/page.tsx b/app/merchant/wallet/guide/page.tsx new file mode 100644 index 0000000..2045ed8 --- /dev/null +++ b/app/merchant/wallet/guide/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { motion } from "framer-motion"; +import Header from "@/components/common/Header"; +import GuideIntroCard from "@/app/merchant/wallet/guide/components/GuideIntroCard" +import GuideList from "@/app/merchant/wallet/guide/components/GuideList"; +import InfoBox from "@/app/merchant/wallet/guide/components/InfoBox"; + +export default function WalletGuidePage() { + return ( +
+
+ +
+ + + + +

더 자세한 내용은 고객센터(1234-5678)로 문의해주세요.

+

운영 시간: 평일 09:00 ~ 18:00

+
+
+
+
+ ); +} diff --git a/app/merchant/wallet/page.tsx b/app/merchant/wallet/page.tsx new file mode 100644 index 0000000..aa5cea2 --- /dev/null +++ b/app/merchant/wallet/page.tsx @@ -0,0 +1,91 @@ +"use client" + +import { useEffect, useState, Suspense } from "react" +import { useSearchParams, useRouter } from "next/navigation" +import Header from "@/components/common/Header" +import { fetchMerchantWalletInfo } from "../dashboard/api/merchant-wallet-info" +import WalletCard from "@/app/merchant/wallet/components/WalletCard" +import ConvertButton from "@/app/merchant/wallet/components/ConvertButton" +import WalletGuide from "@/app/merchant/wallet/components/WalletGuide" +import { + fetchMerchantRecentTransactions, + type MerchantTransaction, +} from "@/app/merchant/dashboard/api/merchant-recent-transactions" +import MerchantRecentTransaction from "@/app/merchant/dashboard/components/MerchantRecentTransaction" + +function MerchantWalletContent() { + const router = useRouter() + const searchParams = useSearchParams() + const refresh = searchParams.get("refresh") + + const [recentMerchantTransactions, setRecentMerchantTransactions] = useState([]) + const [loading, setLoading] = useState(true) + + const [walletInfo, setWalletInfo] = useState<{ + storeName: string + accountNumber: string + tokenBalance: number + depositBalance: number + } | null>(null) + + useEffect(() => { + const fetchData = async () => { + try { + fetchMerchantWalletInfo() + .then((data) => { + setWalletInfo(data) + }) + .catch((err) => { + console.error("지갑 정보 로딩 실패:", err) + }) + + fetchMerchantRecentTransactions(3) + .then(setRecentMerchantTransactions) + .catch((err) => { + console.error("거래내역 조회 실패:", err) + }) + .finally(() => { + setLoading(false) + }) + } catch (error) { + console.error("API 요청 오류:", error) + alert("지갑 데이터를 불러오는 중 오류 발생") + } finally { + setLoading(false) + } + } + + fetchData() + }, [refresh]) + + return ( +
+
+
+ {walletInfo && ( + + )} +
+ + + + + +
+
+
+ ) +} + +export default function MerchantWalletPage() { + return ( + Loading...
}> + + + ) +} diff --git a/app/merchant/wallet/totaltransaction/[id]/page.tsx b/app/merchant/wallet/totaltransaction/[id]/page.tsx new file mode 100644 index 0000000..d0088d6 --- /dev/null +++ b/app/merchant/wallet/totaltransaction/[id]/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useParams } from "next/navigation"; +import { motion } from "framer-motion"; +import Header from "@/components/common/Header"; +import TransactionCardContent from "@/components/common/TransactionCardContent"; +import { getApiUrl } from "@/lib/getApiUrl"; +import { getCookie } from "@/lib/cookies"; +import {fetchTransactionDetail} from "@/app/wallet/api/fetch-transactions-detail"; + +const API_URL = getApiUrl(); + +interface TransactionDetail { + id: number; + type: string; + amount: number; + displayDescription: string; + createdAt: string; +} + +export default function TransactionDetailPage() { + const params = useParams(); + const id = typeof params?.id === "string" ? params.id : undefined; + + const [transaction, setTransaction] = useState( + null + ); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) { + setLoading(false); + return; + } + + fetchTransactionDetail(id) + .then((data) => { + setTransaction(data); + }) + .catch((error) => { + console.error("거래내역 조회 오류:", error); + }) + .finally(() => { + setLoading(false); + }); + }, [id]); + + + if (loading) { + return ( +
+

거래내역을 불러오는 중...

+
+ ); + } + + if (!transaction) { + return ( +
+

거래내역을 찾을 수 없습니다.

+
+ ); + } + + const isNegative = transaction.amount < 0; + const formattedAmount = `${isNegative ? "" : "+"}${Math.abs( + transaction.amount + ).toLocaleString()}원`; + + const date = new Date(transaction.createdAt); + const formattedDate = `${date.getFullYear()}년 ${ + date.getMonth() + 1 + }월 ${date.getDate()}일`; + + const hours = date.getHours(); + const ampm = hours < 12 ? "오전" : "오후"; + const displayHours = hours % 12 || 12; + const formattedTime = `${ampm} ${displayHours}:${String( + date.getMinutes() + ).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`; + + const getKoreanType = (type: string) => { + switch (type) { + case "PAYMENT": + return "결제"; + case "CONVERT": + return "변환"; + default: + return type; + } + }; + + return ( +
+
+ + + + + + +

거래 정보

+ +
+
거래 유형
+
{getKoreanType(transaction.type)}
+
+ +
+
거래 설명
+
{transaction.displayDescription}
+
+
+ +
+ + +

시간 정보

+ +
+
거래 일자
+
{formattedDate}
+
+ +
+
거래 시간
+
{formattedTime}
+
+
+
+ ); +} diff --git a/app/merchant/wallet/totaltransaction/components/Category.tsx b/app/merchant/wallet/totaltransaction/components/Category.tsx new file mode 100644 index 0000000..3ec138b --- /dev/null +++ b/app/merchant/wallet/totaltransaction/components/Category.tsx @@ -0,0 +1,41 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface CategoryOption { + label: string; + value: string; +} + +interface CategoryProps { + label: string; + options: CategoryOption[]; + value?: string; + onChange: (value: string) => void; +} + +export default function Category({ + label, + options, + onChange, + value, +}: CategoryProps) { + return ( + + ); +} diff --git a/app/merchant/wallet/totaltransaction/components/SearchBar.tsx b/app/merchant/wallet/totaltransaction/components/SearchBar.tsx new file mode 100644 index 0000000..f7db1c5 --- /dev/null +++ b/app/merchant/wallet/totaltransaction/components/SearchBar.tsx @@ -0,0 +1,21 @@ +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; + +interface SearchBarProps { + value: string; + onChange: (v: string) => void; +} + +export default function SearchBar({ value, onChange }: SearchBarProps) { + return ( +
+ + onChange(e.target.value)} + className="pl-10 h-10 rounded-lg border-[#E0E0E0] bg-white" + /> +
+ ); +} diff --git a/app/merchant/wallet/totaltransaction/page.tsx b/app/merchant/wallet/totaltransaction/page.tsx new file mode 100644 index 0000000..34914b8 --- /dev/null +++ b/app/merchant/wallet/totaltransaction/page.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { fetchMerchantWalletInfo } from "@/app/merchant/dashboard/api/merchant-wallet-info"; +import fetchMerchantTransactions from "@/app/merchant/dashboard/api/merchant-recent-transactions"; +import Header from "@/components/common/Header"; +import { RotateCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import SearchBar from "./components/SearchBar"; +import Calendar from "@/components/common/Calendar"; +import Category from "@/app/merchant/wallet/totaltransaction/components/Category"; +import TransactionList from "@/app/merchant/dashboard/components/TransactionList"; +import { SkeletonLoader } from "@/app/wallet/totaltransaction/loading/skeleton"; + +interface WalletInfo { + storeName: string; + accountNumber: string; + tokenBalance: number; + depositBalance: number; +} + +interface MerchantTransaction { + id: number; + type: string; + amount: number; + displayDescription: string; + createdAt: string; +} + +export default function TransactionsPage() { + const [walletInfo, setWalletInfo] = useState(null); + const [merchantTransactions, setMerchantTransactions] = useState< + MerchantTransaction[] + >([]); + const [loading, setLoading] = useState(true); + + const [searchTerm, setSearchTerm] = useState(""); + const [date, setDate] = useState<{ from?: Date; to?: Date } | undefined>( + undefined + ); + + const [type, setType] = useState("전체"); + const [period, setPeriod] = useState("전체 기간"); + + const handleResetFilters = () => { + setType("전체"); + setPeriod("전체 기간"); + }; + + const typeOptions = [ + { label: "전체", value: "전체" }, + { label: "정산", value: "정산" }, + { label: "변환", value: "변환" }, + ]; + + const periodOptions = [ + { label: "전체 기간", value: "전체 기간" }, + { label: "최근 1주일", value: "week" }, + { label: "최근 1개월", value: "month" }, + ]; + + useEffect(() => { + const fetchData = async () => { + try { + const wallet = await fetchMerchantWalletInfo(); + setWalletInfo(wallet); + } catch (error) { + console.error("지갑 정보 로드 실패:", error); + } + }; + + fetchData(); + }, []); + + useEffect(() => { + fetchMerchantTransactions() + .then(setMerchantTransactions) + .catch((error) => { + console.error("거래내역 조회 오류:", error); + alert("거래내역 불러오기 중 오류 발생"); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + const filteredTransactions = merchantTransactions.filter((tx) => { + if (!tx.displayDescription) return false; + if ( + searchTerm && + !tx.displayDescription?.toLowerCase().includes(searchTerm.toLowerCase()) + ) { + return false; + } + + if (type !== "전체") { + const typeMap: Record = { + 정산: "RECEIVE", + 변환: "CONVERT", + }; + if (tx.type !== typeMap[type]) return false; + } + + if (period !== "전체 기간") { + const txDate = new Date(tx.createdAt); + const today = new Date(); + const diff = today.getTime() - txDate.getTime(); + + if (period === "week" && diff > 7 * 86400000) return false; + if (period === "month" && diff > 30 * 86400000) return false; + } + + if (date?.from) { + const txDate = new Date(tx.createdAt); + const from = new Date(date.from); + const to = date.to ? new Date(date.to) : from; + + txDate.setHours(0, 0, 0, 0); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + + if (txDate < from || txDate > to) return false; + } + + return true; + }); + + if (loading) { + return ; + } + + return ( +
+
+ +
+
+ + +
+ +
+
+ {date?.from + ? `${date.from.toLocaleDateString()} ${ + date.to ? `~ ${date.to.toLocaleDateString()}` : "" + }` + : "날짜를 선택해주세요"} +
+ +
+ +
+ + + +
+
+ +
+ +
+
+ ); +} diff --git a/app/mobile-layout.tsx b/app/mobile-layout.tsx new file mode 100644 index 0000000..75ea52a --- /dev/null +++ b/app/mobile-layout.tsx @@ -0,0 +1,23 @@ +"use client" + +import type React from "react" +import { usePathname } from "next/navigation" + +interface MobileLayoutProps { + children: React.ReactNode +} + +export default function MobileLayout({ children }: MobileLayoutProps) { + const pathname = usePathname() + + // admin 경로는 모바일 레이아웃을 적용하지 않음 + if (pathname.startsWith("/admin")) { + return <>{children} + } + + return ( +
+
{children}
+
+ ) +} diff --git a/app/my-vouchers/components/MyStoreList.tsx b/app/my-vouchers/components/MyStoreList.tsx new file mode 100644 index 0000000..24fa86c --- /dev/null +++ b/app/my-vouchers/components/MyStoreList.tsx @@ -0,0 +1,59 @@ +"use client" + +import { useEffect, useState } from "react" +import { MyStoreModal } from "./MyStoreModal" +import { getMyVoucherStores } from "@/lib/api/voucher" + +interface MyStoreListProps { + voucherOwnershipId: number +} + +export function MyStoreList({ voucherOwnershipId }: MyStoreListProps) { + const [previewStores, setPreviewStores] = useState([]) + const [isModalOpen, setIsModalOpen] = useState(false) + + useEffect(() => { + async function fetchPreviewStores() { + console.log("voucherOwnershipId:", voucherOwnershipId) + + try { + const res = await getMyVoucherStores(voucherOwnershipId, 0, 5) + const names = res.content.map((store: any) => store.storeName) + setPreviewStores(names) + } catch (error: any) { + console.error("사용처 불러오기 실패:", error.response?.data || error.message) + setPreviewStores([]) + } + } + + fetchPreviewStores() + }, [voucherOwnershipId]) + + return ( +
+
    + {previewStores.map((storeName, idx) => ( +
  • {storeName || "(이름 없음)"}
  • + ))} +
+ + {previewStores.length > 0 && ( +
+ +
+ )} + + {isModalOpen && ( + setIsModalOpen(false)} + /> + )} +
+ ) +} \ No newline at end of file diff --git a/app/my-vouchers/components/MyStoreModal.tsx b/app/my-vouchers/components/MyStoreModal.tsx new file mode 100644 index 0000000..ab04d59 --- /dev/null +++ b/app/my-vouchers/components/MyStoreModal.tsx @@ -0,0 +1,200 @@ +"use client" + +import { useEffect, useState } from "react" +import { X } from "lucide-react" +import { getMyVoucherStores } from "@/lib/api/voucher" +import Pagination from "@/app/vouchers/components/Pagination" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" +import { motion, AnimatePresence } from "framer-motion" + +interface MyStoreModalProps { + voucherOwnershipId: number + onClose: () => void +} + +export function MyStoreModal({ voucherOwnershipId, onClose }: MyStoreModalProps) { + const [stores, setStores] = useState([]) + const [page, setPage] = useState(0) + const [totalPages, setTotalPages] = useState(0) + const [totalCount, setTotalCount] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const pageSize = 10 + + const resetData = () => { + setStores([]) + setPage(0) + setTotalPages(0) + setTotalCount(0) + setIsLoading(true) + } + + useEffect(() => { + resetData() + }, [voucherOwnershipId]) + + useEffect(() => { + + // 모달이 열리면 스크롤 방지 + document.body.style.overflow = "hidden"; + + let isAborted = false + + async function fetchStores() { + setIsLoading(true) + try { + const res = await getMyVoucherStores(voucherOwnershipId, page, pageSize) + const names = res.content.map((store: any) => store.storeName) + + if (!isAborted) { + const newTotalPages = Math.max(1, Math.ceil(res.totalElements / pageSize)) + setTotalPages(newTotalPages) + setTotalCount(res.totalElements) + + if (page >= newTotalPages && newTotalPages > 0) { + setPage(0) + } else { + setStores(names) + } + } + } catch (error) { + if (!isAborted) { + console.error("전체 사용처 불러오기 실패:", error) + setStores([]) + setTotalPages(0) + setTotalCount(0) + } + } finally { + if (!isAborted) setIsLoading(false) + } + } + + fetchStores() + + return () => { + document.body.style.overflow = ""; + isAborted = true + } + + }, [voucherOwnershipId, page]) + + return ( + + + e.stopPropagation()} + > + {/* 헤더 */} +
+
+

전체 사용처

+ + {totalCount}개 + +
+ +
+ + {/* 리스트 */} + +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, idx) => ( +
+ ))} +
+ ) : stores.length > 0 ? ( +
    + {stores.map((store, idx) => ( + +
    + {store} + + ))} +
+ ) : ( +
+
+ + + +
+

사용처 정보가 없습니다.

+

등록된 사용처가 없거나 정보를 불러오는데 실패했습니다.

+
+ )} +
+ + + {/* 페이지네이션 */} + {totalPages > 1 && ( + <> + +
+ { + if (newPage !== page && newPage >= 0 && newPage < totalPages) { + setPage(newPage) + } + }} + /> +
+ + )} + + + + ) +} diff --git a/app/my-vouchers/components/MyVoucherCard.tsx b/app/my-vouchers/components/MyVoucherCard.tsx new file mode 100644 index 0000000..ae1cc55 --- /dev/null +++ b/app/my-vouchers/components/MyVoucherCard.tsx @@ -0,0 +1,234 @@ +"use client" + +import Image from "next/image" +import Link from "next/link" +import { Calendar, MoreVertical, CheckCircle, Trash2, AlertTriangle, X } from "lucide-react" +import { useState } from "react" +import type { MyVoucher } from "@/app/my-vouchers/types/my-voucher" +import StatusBadge from "@/app/my-vouchers/components/StatusBadge" +import { deleteMyVoucher } from "@/lib/api/voucher" + +interface Props { + voucher: MyVoucher + onDelete: (voucherId: number) => void +} + +export default function MyVoucherCard({ voucher, onDelete }: Props) { + const [isModalOpen, setIsModalOpen] = useState(false) + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [isDeleted, setIsDeleted] = useState(false) + const isDisabled = ["USED", "EXPIRED", "CANCELLED"].includes(voucher.status) + + const handleDelete = async () => { + setIsDeleting(true) + try { + await deleteMyVoucher(voucher.id) + console.log(`Voucher ${voucher.id} 삭제 성공`) + setIsDeleted(true) + setTimeout(() => { + setIsDeleted(false) + setIsConfirmModalOpen(false) + setIsModalOpen(false) + onDelete(voucher.id) + }, 2000) + } catch (error) { + console.error(`Voucher ${voucher.id} 삭제 실패:`, error) + } finally { + setIsDeleting(false) + } + } + + return ( + <> + +
+ {/* 이미지 */} + {voucher.imageUrl} + + {/* 블러 처리 */} + {isDisabled &&
} + +
+
+ + +
+
+ + {voucher.contact} + +

{voucher.name}

+
+
+ +
+
+
+

잔액

+

{voucher.remainingAmount.toLocaleString()}원

+
+
+

총 금액

+

{voucher.price.toLocaleString()}원

+
+
+ + {/* 게이지 바 */} +
+
+
+ +
+ +

유효기간: {voucher.validDate}

+
+
+ + + {/* 첫 번째 삭제 확인 모달 */} + {isModalOpen && ( +
+
+
+

바우처 삭제

+ +
+ +
+
+ +
+
+ +

해당 바우처를 삭제하시겠습니까?

+ +
+ + +
+
+
+ )} + + {/* 두 번째 확인 모달 */} + {isConfirmModalOpen && ( +
+
+ {isDeleted ? ( +
+
+ +
+

삭제 완료

+

바우처가 성공적으로 삭제되었습니다.

+
+ ) : ( + <> +
+

최종 확인

+ +
+ +
+
+ +
+
+ +
+

정말 삭제하시겠습니까?

+

+ 삭제 후 복구할 수 없으며 바우처를 삭제하면 남은 잔액이 환불되지 않습니다. +

+
+ +
+ + +
+ + )} +
+
+ )} + + ) +} \ No newline at end of file diff --git a/app/my-vouchers/components/MyVoucherHeader.tsx b/app/my-vouchers/components/MyVoucherHeader.tsx new file mode 100644 index 0000000..a6ee383 --- /dev/null +++ b/app/my-vouchers/components/MyVoucherHeader.tsx @@ -0,0 +1,53 @@ +"use client" + +import Image from "next/image" +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" +import StatusBadge from "@/app/my-vouchers/components/StatusBadge" +import { MyVoucherDetail } from "@/app/my-vouchers/types/my-voucher" + +interface Props { + voucher: MyVoucherDetail +} + +export default function MyVoucherHeader({ voucher }: Props) { + const router = useRouter() + const isDisabled = ["USED", "EXPIRED", "CANCELLED"].includes(voucher.status) + + return ( +
+
+ {voucher.voucherName +
+
+ + {/* 뒤로가기 버튼 */} + + + {/* 상태 뱃지 */} +
+ +
+ + {/* 바우처 정보 */} +
+ + {voucher.voucherContact || "문의처 정보가 없습니다."} + +

{voucher.voucherName}

+
+
+ ) +} \ No newline at end of file diff --git a/app/my-vouchers/components/MyVoucherInfo.tsx b/app/my-vouchers/components/MyVoucherInfo.tsx new file mode 100644 index 0000000..aeade47 --- /dev/null +++ b/app/my-vouchers/components/MyVoucherInfo.tsx @@ -0,0 +1,67 @@ +"use client" + +import { Calendar } from "lucide-react" +import { MyVoucherDetail } from "@/app/my-vouchers/types/my-voucher" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" + +interface Props { + voucher: MyVoucherDetail +} + +export default function MyVoucherInfo({ voucher }: Props) { + const router = useRouter() + + const isDisabled = ["USED", "EXPIRED", "CANCELLED"].includes(voucher.status) + + const handleClick = () => { + if (!isDisabled) { + router.push("/payment") + } + } + + return ( +
+
+
+

잔액

+

+ {voucher.remainingAmount.toLocaleString()}원 +

+
+
+

총 금액

+

+ {voucher.price.toLocaleString()}원 +

+
+
+
+
+
+
+ +

+ 유효기간: {voucher.voucherValidDate} +

+
+ + +
+ ) +} \ No newline at end of file diff --git a/app/my-vouchers/components/MyVoucherList.tsx b/app/my-vouchers/components/MyVoucherList.tsx new file mode 100644 index 0000000..749a811 --- /dev/null +++ b/app/my-vouchers/components/MyVoucherList.tsx @@ -0,0 +1,61 @@ +"use client" + +import MyVoucherCard from "./MyVoucherCard" +import type { MyVoucher } from "@/app/my-vouchers/types/my-voucher" + +interface Props { + vouchers: MyVoucher[] + loading: boolean + onDelete: (voucherId: number) => void +} + +export default function MyVoucherList({ vouchers, loading, onDelete }: Props) { +if (loading) { + return ( +
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) + } + + + if (!Array.isArray(vouchers)) { + return

잘못된 데이터 형식입니다.

+ } + + if (vouchers.length === 0) { + return ( +
+

+ 보유한 바우처가 없습니다. +

+
+ ) + } + + return ( +
+ {vouchers.map((voucher) => ( + + ))} +
+ ) +} \ No newline at end of file diff --git a/app/my-vouchers/components/MyVoucherSearchBar.tsx b/app/my-vouchers/components/MyVoucherSearchBar.tsx new file mode 100644 index 0000000..62d6f1d --- /dev/null +++ b/app/my-vouchers/components/MyVoucherSearchBar.tsx @@ -0,0 +1,46 @@ +"use client" + +import { Input } from "@/components/ui/input" +import { Search } from "lucide-react" +import { useRouter, useSearchParams } from "next/navigation" +import { useEffect, useState } from "react" + +export default function VoucherSearchBar() { + const router = useRouter() + const searchParams = useSearchParams() + const initialValue = searchParams.get("searchKeyword") || "" + + const [keyword, setKeyword] = useState(initialValue) + const [debounced, setDebounced] = useState(initialValue) + + // 디바운싱 + useEffect(() => { + const timer = setTimeout(() => { + setDebounced(keyword) + }, 1000) + return () => clearTimeout(timer) + }, [keyword]) + + // 디바운싱된 값 URL 반영 + useEffect(() => { + const params = new URLSearchParams(searchParams) + if (debounced) { + params.set("searchKeyword", debounced) + } else { + params.delete("searchKeyword") + } + router.push(`/my-vouchers?${params.toString()}`) + }, [debounced]) + + return ( +
+ + setKeyword(e.target.value)} + /> +
+ ) +} diff --git a/app/my-vouchers/components/MyVoucherTabFilter.tsx b/app/my-vouchers/components/MyVoucherTabFilter.tsx new file mode 100644 index 0000000..bbf8a96 --- /dev/null +++ b/app/my-vouchers/components/MyVoucherTabFilter.tsx @@ -0,0 +1,30 @@ +'use client' + +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { useRouter, useSearchParams } from "next/navigation" + +export default function MyVoucherTabFilter() { + const router = useRouter() + const searchParams = useSearchParams() + const sort = searchParams.get("sort") || "recent" + const userId = searchParams.get("userId") + const query = searchParams.get("q") + + const handleSortChange = (value: string) => { + const params = new URLSearchParams(searchParams) + params.set("sort", value) + if (userId) params.set("userId", userId) + if (query) params.set("searchKeyword", query) + router.push(`/my-vouchers?${params.toString()}`) + } + + return ( + + + 최신순 + 금액순 + 만료순 + + + ) +} diff --git a/app/my-vouchers/components/StatusBadge.tsx b/app/my-vouchers/components/StatusBadge.tsx new file mode 100644 index 0000000..aa414ea --- /dev/null +++ b/app/my-vouchers/components/StatusBadge.tsx @@ -0,0 +1,36 @@ +"use client" + +import { Badge } from "@/components/ui/badge" + +interface Props { + status: string +} + +export default function StatusBadge({ status }: Props) { + let label = "" + let className = "" + + switch (status) { + case "AVAILABLE": + label = "사용 가능" + className = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300" + break + case "USED": + label = "사용 완료" + className = "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300" + break + case "EXPIRED": + label = "기간 만료" + className = "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300" + break + case "CANCELLED": + label = "취소됨" + className = "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300" + break + default: + label = "알 수 없음" + className = "bg-gray-200 text-gray-800" + } + + return {label} +} diff --git a/app/my-vouchers/details/[voucherOwnershipId]/page.tsx b/app/my-vouchers/details/[voucherOwnershipId]/page.tsx new file mode 100644 index 0000000..6172ed2 --- /dev/null +++ b/app/my-vouchers/details/[voucherOwnershipId]/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { getMyDetailVouchers } from "@/lib/api/voucher"; +import type { MyVoucherDetail } from "@/app/my-vouchers/types/my-voucher"; +import MyVoucherHeader from "@/app/my-vouchers/components/MyVoucherHeader"; +import MyVoucherInfo from "@/app/my-vouchers/components/MyVoucherInfo"; +import { ExpandableSection } from "@/app/vouchers/components/ExpandableSection"; +import { FileText, Building, CreditCard } from "lucide-react"; +import { MyStoreList } from "@/app/my-vouchers/components/MyStoreList"; + +export default function MyVoucherDetailPage() { + const { voucherOwnershipId } = useParams(); + const id = Number(voucherOwnershipId); + + const [voucher, setVoucher] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchDetail = async () => { + try { + const data = await getMyDetailVouchers(id); + setVoucher(data); + } catch (e) { + console.error("바우처 조회 실패:", e); + setError("바우처를 불러오는 중에 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + fetchDetail(); + }, [id]); + + if (loading) { + return ( +
+ {/* 상단 영역 (헤더 대체) */} +
+ + {/* 본문 스켈레톤 */} +
+
+
+
+
+
+
+ ); + } + + if (error) return

{error}

; + if (!voucher) return

바우처를 찾을 수 없습니다.

; + + return ( +
+ + +
+ + + } + > +

+ {voucher.voucherDetailDescription || "설명이 없습니다."} +

+
+ + } + > + + + + } + > +

+ {voucher.voucherRefundPolicy || "환불 정책 정보가 없습니다."} +

+
+ +
+

문의처

+

{voucher.voucherContact}

+
+
+
+ ); +} diff --git a/app/my-vouchers/page.tsx b/app/my-vouchers/page.tsx new file mode 100644 index 0000000..88ca5ed --- /dev/null +++ b/app/my-vouchers/page.tsx @@ -0,0 +1,68 @@ +"use client" + +import { useEffect, useState, Suspense } from "react" +import { useSearchParams } from "next/navigation" +import VoucherHeader from "@/app/vouchers/components/VoucherHeader" +import MyVoucherList from "@/app/my-vouchers/components/MyVoucherList" +import MyVoucherTabFilter from "@/app/my-vouchers/components/MyVoucherTabFilter" +import MyVoucherSearchBar from "@/app/my-vouchers/components/MyVoucherSearchBar" +import { getMyVouchers } from "@/lib/api/voucher" +import type { MyVoucher } from "@/app/my-vouchers/types/my-voucher" + +function MyVouchersContent() { + const [myVouchers, setMyVouchers] = useState([]) + const [loading, setLoading] = useState(true) + + const searchParams = useSearchParams() + const keyword = searchParams.get("searchKeyword") || "" + const sort = searchParams.get("sort") || "recent" + + useEffect(() => { + const fetch = async () => { + setLoading(true) + try { + const res = await getMyVouchers({ + searchKeyword: keyword, + sort, + page: 0, + size: 15, + }) + console.log("fetchMyVouchers 응답:", res) + setMyVouchers(res.content) + } catch (e) { + console.error("내 바우처 조회 실패:", e) + } finally { + setLoading(false) + } + } + fetch() + }, [keyword, sort]) + + const handleDelete = (id: number) => setMyVouchers((prev) => prev.filter((v) => v.id !== id)) + + return ( +
+ + +
+ +
+ +
+
+ +
+

내 바우처 ({myVouchers.length})

+ +
+
+ ) +} + +export default function MyVouchersPage() { + return ( + Loading...
}> + + + ) +} diff --git a/app/my-vouchers/types/my-voucher.ts b/app/my-vouchers/types/my-voucher.ts new file mode 100644 index 0000000..0b3c9e4 --- /dev/null +++ b/app/my-vouchers/types/my-voucher.ts @@ -0,0 +1,45 @@ +import { StoreResponse } from '@/types/store' +export interface MyVoucher { + id: number + name: string + contact: string + price: number + remainingAmount: number + // isVoucher:boolean + status: string + validDate: string + imageUrl: string + +}; + + export interface MyVoucherDetail { + id: number + voucherName: string + voucherContact: string + voucherValidDate: string + voucherDetailDescription: string + voucherRefundPolicy: string + remainingAmount: number + price: number + status: string + imageUrl: string + stores: { + content: StoreResponse[] + totalPages: number + totalElements: number + size: number + number: number + first: boolean + last: boolean + } + } + +export interface MyVoucherSearchParams { + storeCategory?: string; + searchKeyword?: string; + sort?: string; + direction?: string; + userId?: number; + page?: number; + size?: number; +} \ No newline at end of file diff --git a/app/mypage/api/user-info.ts b/app/mypage/api/user-info.ts new file mode 100644 index 0000000..1ba08a9 --- /dev/null +++ b/app/mypage/api/user-info.ts @@ -0,0 +1,28 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export interface UserInfo { + id: number; + name: string; + email: string; + phoneNumber: string; +} + +export async function getUserInfo(accessToken: string): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/info`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + credentials: "include", + }); + + if (!res.ok) { + throw new Error("유저 정보를 불러오지 못했습니다."); + } + + const data = await res.json(); + return data.result; +} \ No newline at end of file diff --git a/app/mypage/api/user-logout.ts b/app/mypage/api/user-logout.ts new file mode 100644 index 0000000..efb8af8 --- /dev/null +++ b/app/mypage/api/user-logout.ts @@ -0,0 +1,17 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export const logout = async () => { + const res = await fetchWithAuth(`${API_URL}/api/users/logout`, { + method: "POST", + credentials: "include", // refreshToken 쿠키 포함 + }); + + if (!res.ok) { + throw new Error("로그아웃 실패"); + } + + return res; +}; \ No newline at end of file diff --git a/app/mypage/change-password/api/password-update.ts b/app/mypage/change-password/api/password-update.ts new file mode 100644 index 0000000..164036b --- /dev/null +++ b/app/mypage/change-password/api/password-update.ts @@ -0,0 +1,25 @@ +import {getApiUrl} from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export async function updatePassword(password: string, newPassword: string) { + const res = await fetchWithAuth(`${API_URL}/api/users/password-update`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + password, + newPassword, + }) + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.message || '비밀번호 변경에 실패했습니다.'); + } + + return true; +} \ No newline at end of file diff --git a/app/mypage/change-password/components/ChangePasswordCardHeader.tsx b/app/mypage/change-password/components/ChangePasswordCardHeader.tsx new file mode 100644 index 0000000..172f70e --- /dev/null +++ b/app/mypage/change-password/components/ChangePasswordCardHeader.tsx @@ -0,0 +1,18 @@ +import { CardHeader, CardTitle } from "@/components/ui/card" +import { Lock } from "lucide-react" + +export default function ChangePasswordCardHeader() { + return ( + + +
+ + 비밀번호는 주기적으로 변경하는 것이 좋아요 +
+

+ 더 안전한 서비스 이용을 위해 지금 바꿔보세요 +

+
+
+ ) +} \ No newline at end of file diff --git a/app/mypage/change-password/components/ChangePasswordForm.tsx b/app/mypage/change-password/components/ChangePasswordForm.tsx new file mode 100644 index 0000000..572bdf4 --- /dev/null +++ b/app/mypage/change-password/components/ChangePasswordForm.tsx @@ -0,0 +1,110 @@ +"use client" + +import { useState } from "react" +import { toast } from "@/hooks/use-toast" +import { Card, CardContent } from "@/components/ui/card" +import Image from "next/image" +import PasswordInput from "./PasswordInput" +import { Button } from "@/components/ui/button" +import ChangePasswordCardHeader from "@/app/mypage/change-password/components/ChangePasswordCardHeader"; +import {updatePassword} from "@/app/mypage/change-password/api/password-update"; + +export default function ChangePasswordForm({ onSuccess }: { onSuccess: () => void }) { + const [formData, setFormData] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }) + const [errors, setErrors] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + if (errors[name as keyof typeof errors]) { + setErrors((prev) => ({ ...prev, [name]: "" })) + } + } + + const validateForm = () => { + let isValid = true + const newErrors = { ...errors } + + if (!formData.currentPassword) { + newErrors.currentPassword = "현재 비밀번호를 입력해주세요." + isValid = false + } + if (!formData.newPassword) { + newErrors.newPassword = "새 비밀번호를 입력해주세요." + isValid = false + } else if (formData.newPassword.length < 8) { + newErrors.newPassword = "비밀번호는 8자 이상이어야 합니다." + isValid = false + } else if (formData.newPassword === formData.currentPassword) { + newErrors.newPassword = "현재 비밀번호와 다른 비밀번호를 입력해주세요." + isValid = false + } + if (!formData.confirmPassword) { + newErrors.confirmPassword = "비밀번호 확인을 입력해주세요." + isValid = false + } else if (formData.confirmPassword !== formData.newPassword) { + newErrors.confirmPassword = "비밀번호가 일치하지 않습니다." + isValid = false + } + + setErrors(newErrors) + return isValid + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validateForm()) return; + + setIsSubmitting(true); + + try { + // 실제 API 호출 + await updatePassword(formData.currentPassword, formData.newPassword); + + toast({ + title: "비밀번호 변경 완료", + description: "비밀번호가 성공적으로 변경되었습니다.", + }); + + onSuccess(); + } catch (error) { + console.error(error); + toast({ + title: "오류 발생", + description: "비밀번호 변경 중 오류가 발생했습니다. 다시 시도해주세요.", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + }; + + + return ( + + + +
+ 보안 마스코트 +
+
+ + + + + +
+
+ ) +} diff --git a/app/mypage/change-password/components/ChangePasswordHeader.tsx b/app/mypage/change-password/components/ChangePasswordHeader.tsx new file mode 100644 index 0000000..d57eefd --- /dev/null +++ b/app/mypage/change-password/components/ChangePasswordHeader.tsx @@ -0,0 +1,28 @@ +"use client" + +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +export default function ChangePasswordHeader() { + const router = useRouter() + + return ( + + +

비밀번호 변경

+
+ ) +} \ No newline at end of file diff --git a/app/mypage/change-password/components/ChangePasswordSuccess.tsx b/app/mypage/change-password/components/ChangePasswordSuccess.tsx new file mode 100644 index 0000000..9f60a40 --- /dev/null +++ b/app/mypage/change-password/components/ChangePasswordSuccess.tsx @@ -0,0 +1,35 @@ +"use client" + +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { CheckCircle } from "lucide-react" +import { useRouter } from "next/navigation" + +export default function ChangePasswordSuccess() { + const router = useRouter() + + return ( + + + +
+ +
+

비밀번호 변경 완료!

+

비밀번호가 성공적으로 변경되었습니다.

+ +
+
+
+ ) +} diff --git a/app/mypage/change-password/components/PasswordInput.tsx b/app/mypage/change-password/components/PasswordInput.tsx new file mode 100644 index 0000000..44efd86 --- /dev/null +++ b/app/mypage/change-password/components/PasswordInput.tsx @@ -0,0 +1,63 @@ +"use client" + +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Eye, EyeOff, AlertCircle } from "lucide-react" +import { useState } from "react" +import { motion } from "framer-motion" + +interface PasswordInputProps extends React.InputHTMLAttributes { + id: string + label: string + name: string + value: string + error?: string + placeholder?: string + onChange: (e: React.ChangeEvent) => void +} + +export default function PasswordInput({ + id, + label, + name, + value, + error, + placeholder, + onChange, + ...props // ✅ 받기 +}: PasswordInputProps) { + const [show, setShow] = useState(false) + + return ( + + +
+ + +
+ {error && ( +

+ + {error} +

+ )} +
+ ) +} diff --git a/app/mypage/change-password/page.tsx b/app/mypage/change-password/page.tsx new file mode 100644 index 0000000..bfa5176 --- /dev/null +++ b/app/mypage/change-password/page.tsx @@ -0,0 +1,36 @@ +"use client" + +import { useState } from "react" +import { motion } from "framer-motion" +import ChangePasswordHeader from "@/app/mypage/change-password/components/ChangePasswordHeader" +import ChangePasswordForm from "@/app/mypage/change-password/components/ChangePasswordForm" +import ChangePasswordSuccess from "@/app/mypage/change-password/components/ChangePasswordSuccess" + +export default function ChangePasswordPage() { + const [step, setStep] = useState<"form" | "success">("form") + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + }, + }, + } + + return ( +
+ +
+ + {step === "form" ? ( + setStep("success")} /> + ) : ( + + )} + +
+
+ ) +} diff --git a/app/mypage/change-pin/api/verify-simple-password.ts b/app/mypage/change-pin/api/verify-simple-password.ts new file mode 100644 index 0000000..6c66ee4 --- /dev/null +++ b/app/mypage/change-pin/api/verify-simple-password.ts @@ -0,0 +1,38 @@ +import { getApiUrl } from "@/lib/getApiUrl" +import { fetchWithAuth } from "@/lib/fetchWithAuth" + +const API_URL = getApiUrl() + +// 간편 비밀번호 유효성 검증 +export async function verifySimplePassword(accessToken: string, simplePassword: string): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/simple-password/verify`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ simplePassword }), + credentials: "include", + }) + + if (!res.ok) { + throw new Error("간편 비밀번호가 올바르지 않습니다.") + } +} + +// 간편 비밀번호 수정 요청 +export async function updateSimplePassword(accessToken: string, simplePassword: string): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/simple-password/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ simplePassword: simplePassword }), + credentials: "include", + }) + + if (!res.ok){ + throw new Error("간편 비밀번호 수정에 실패했습니다.") + } +} diff --git a/app/mypage/change-pin/components/ChangePinHeader.tsx b/app/mypage/change-pin/components/ChangePinHeader.tsx new file mode 100644 index 0000000..3859963 --- /dev/null +++ b/app/mypage/change-pin/components/ChangePinHeader.tsx @@ -0,0 +1,21 @@ +"use client" + +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +export default function ChangePinHeader() { + const router = useRouter() + + return ( +
+
+ +

간편 비밀번호 변경

+
+
+
+ ) +} diff --git a/app/mypage/change-pin/components/ChangePinLayout.tsx b/app/mypage/change-pin/components/ChangePinLayout.tsx new file mode 100644 index 0000000..7f38812 --- /dev/null +++ b/app/mypage/change-pin/components/ChangePinLayout.tsx @@ -0,0 +1,21 @@ +"use client" + +import { ReactNode } from "react" +import ChangePinHeader from "./ChangePinHeader" + +interface Props { + children: ReactNode +} + +export default function ChangePinLayout({ children }: Props) { + return ( +
+ +
+
+ {children} +
+
+
+ ) +} diff --git a/app/mypage/change-pin/components/StepComplete.tsx b/app/mypage/change-pin/components/StepComplete.tsx new file mode 100644 index 0000000..3fce2ee --- /dev/null +++ b/app/mypage/change-pin/components/StepComplete.tsx @@ -0,0 +1,30 @@ +"use client" + +import { CheckCircle } from "lucide-react" +import { Button } from "@/components/ui/button" +import { motion } from "framer-motion" + +interface Props { + onDone: () => void +} + +export default function StepComplete({ onDone }: Props) { + return ( + + Security Mascot + +

비밀번호 변경 완료

+

간편 비밀번호가 성공적으로 변경되었습니다.

+ + +
+ ) +} \ No newline at end of file diff --git a/app/mypage/change-pin/components/StepConfirmPin.tsx b/app/mypage/change-pin/components/StepConfirmPin.tsx new file mode 100644 index 0000000..d76be6a --- /dev/null +++ b/app/mypage/change-pin/components/StepConfirmPin.tsx @@ -0,0 +1,20 @@ +"use client" + +import { Loader2 } from "lucide-react" +import VirtualKeypad from "@/components/virtual-keypad" + +interface Props { + onSubmit: (pin: string) => void + loading: boolean +} + +export default function StepConfirmPin({ onSubmit, loading }: Props) { + return loading ? ( +
+ +

비밀번호 변경 중...

+
+ ) : ( + + ) +} diff --git a/app/mypage/change-pin/components/StepIntro.tsx b/app/mypage/change-pin/components/StepIntro.tsx new file mode 100644 index 0000000..6c8093d --- /dev/null +++ b/app/mypage/change-pin/components/StepIntro.tsx @@ -0,0 +1,30 @@ +"use client" + +import Image from "next/image" +import { motion } from "framer-motion" + +interface Props { + title: string + subtitle: string +} + +export default function StepIntro({ title, subtitle }: Props) { + return ( + + Security Mascot +

{title}

+

{subtitle}

+
+ ) +} diff --git a/app/mypage/change-pin/components/StepNewPin.tsx b/app/mypage/change-pin/components/StepNewPin.tsx new file mode 100644 index 0000000..08fb779 --- /dev/null +++ b/app/mypage/change-pin/components/StepNewPin.tsx @@ -0,0 +1,11 @@ +"use client" + +import VirtualKeypad from "@/components/virtual-keypad" + +interface Props { + onSubmit: (pin: string) => void +} + +export default function StepNewPin({ onSubmit }: Props) { + return +} diff --git a/app/mypage/change-pin/components/StepVerifyCurrent.tsx b/app/mypage/change-pin/components/StepVerifyCurrent.tsx new file mode 100644 index 0000000..9ea5302 --- /dev/null +++ b/app/mypage/change-pin/components/StepVerifyCurrent.tsx @@ -0,0 +1,46 @@ +"use client" + +import { Alert, AlertDescription } from "@/components/ui/alert" +import { AlertCircle, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import VirtualKeypad from "@/components/virtual-keypad" + +interface Props { + onSubmit: (pin: string) => void + onForgot: () => void + loading: boolean + error?: string +} + +export default function StepVerifyCurrent({ onSubmit, onForgot, loading, error }: Props) { + return ( +
+ {error && ( + + + {error} + + )} + + {loading ? ( +
+ +

인증 중...

+
+ ) : ( + <> + +
+ +
+ + )} +
+ ) +} diff --git a/app/mypage/change-pin/page.tsx b/app/mypage/change-pin/page.tsx new file mode 100644 index 0000000..142c513 --- /dev/null +++ b/app/mypage/change-pin/page.tsx @@ -0,0 +1,174 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { toast } from "@/hooks/use-toast" +import ChangePinHeader from "./components/ChangePinHeader" +import StepIntro from "./components/StepIntro" +import StepVerifyCurrent from "./components/StepVerifyCurrent" +import StepNewPin from "./components/StepNewPin" +import StepConfirmPin from "./components/StepConfirmPin" +import StepComplete from "./components/StepComplete" +import { verifySimplePassword, updateSimplePassword } from "./api/verify-simple-password" +import { getCookie } from "@/lib/cookies" + +export default function ChangePinPage() { + const router = useRouter() + const [step, setStep] = useState<"verify-current" | "new-pin" | "confirm-pin" | "complete">("verify-current") + const [currentPin, setCurrentPin] = useState("") + const [newPin, setNewPin] = useState("") + const [confirmPin, setConfirmPin] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [attempts, setAttempts] = useState(0) + + const handleVerifyCurrent = async (pin: string) => { + setCurrentPin(pin) + setLoading(true) + setError("") + + try { + const accessToken = getCookie("accessToken") + if (!accessToken) throw new Error("로그인이 필요합니다.") + + await verifySimplePassword(accessToken, pin) + + setStep("new-pin") + toast({ + title: "인증 성공", + description: "새로운 간편 비밀번호를 입력해주세요.", + }) + } catch (err: any) { + const newAttempts = attempts + 1 + setAttempts(newAttempts) + + if (newAttempts >= 3) { + setError("비밀번호 입력 횟수를 초과했습니다. 비밀번호 찾기를 이용해주세요.") + } else { + setError(err?.message || `비밀번호가 일치하지 않습니다. (${newAttempts}/3)`) + } + } finally { + setLoading(false) + } + } + + const handleNewPin = (pin: string) => { + if (pin === currentPin) { + toast({ + title: "오류", + description: "현재 비밀번호와 다른 비밀번호를 입력해주세요.", + variant: "destructive", + }) + return + } + + setNewPin(pin) + toast({ + title: "새 비밀번호 입력 완료", + description: "비밀번호 확인을 위해 한번 더 입력해주세요.", + }) + setStep("confirm-pin") + } + + const handleConfirmPin = async (pin: string) => { + setConfirmPin(pin) + setLoading(true) + + try { + if (pin !== newPin) { + toast({ + title: "비밀번호 불일치", + description: "입력한 비밀번호가 일치하지 않습니다.", + variant: "destructive", + }) + setStep("new-pin") + return + } + + const accessToken = getCookie("accessToken") + if (!accessToken) throw new Error("로그인이 필요합니다.") + + await updateSimplePassword(accessToken, pin) + + setStep("complete") + toast({ + title: "비밀번호 변경 성공", + description: "간편 비밀번호가 성공적으로 변경되었습니다.", + }) + } catch (err: any) { + toast({ + title: "오류 발생", + description: err?.message || "비밀번호 변경 중 오류가 발생했습니다.", + variant: "destructive", + }) + setStep("new-pin") + } finally { + setLoading(false) + } + } + + const handleForgotPin = () => router.push("/mypage/reset-pin") + const handleComplete = () => router.push("/mypage") + + const stepContent = { + "verify-current": { + title: "현재 비밀번호 확인", + subtitle: "현재 사용 중인 6자리 비밀번호를 입력해주세요", + }, + "new-pin": { + title: "새 비밀번호 입력", + subtitle: "새로운 6자리 비밀번호를 입력해주세요", + }, + "confirm-pin": { + title: "비밀번호 확인", + subtitle: "새 비밀번호를 다시 한번 입력해주세요", + }, + } + + const pageVariants = { + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -10 }, + } + + return ( +
+ + +
+ {step !== "complete" && ( + + )} + +
+ + {step === "verify-current" && ( + + )} + {step === "new-pin" && } + {step === "confirm-pin" && ( + + )} + {step === "complete" && } + +
+
+
+ ) +} diff --git a/app/mypage/components/LogoutDialog.tsx b/app/mypage/components/LogoutDialog.tsx new file mode 100644 index 0000000..d3e033e --- /dev/null +++ b/app/mypage/components/LogoutDialog.tsx @@ -0,0 +1,76 @@ +"use client" + +import { LogOut } from "lucide-react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from "@/components/ui/dialog" +import { logout } from "@/app/mypage/api/user-logout"; + +export default function LogoutDialog() { + const router = useRouter() + + const handleLogout = async () => { + try { + await logout(); // API 호출 + + // 쿠키 삭제 + document.cookie = "accessToken=; path=/; max-age=0"; + document.cookie = "refreshToken=; path=/; max-age=0"; + + router.push("/login"); + } catch (err) { + console.error("로그아웃 실패:", err); + alert("로그아웃 중 오류가 발생했습니다."); + } + }; + + return ( +
+ + + + + + +
+
+ +
+ 로그아웃 하시겠습니까? + + 로그아웃 하시면 서비스 이용을 위해
다시 로그인해야 합니다. +
+
+
+ +
+ + + + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/mypage/components/MenuList.tsx b/app/mypage/components/MenuList.tsx new file mode 100644 index 0000000..3b34407 --- /dev/null +++ b/app/mypage/components/MenuList.tsx @@ -0,0 +1,57 @@ +"use client" + +import { motion } from "framer-motion" +import { ChevronRight } from "lucide-react" + +interface MenuItem { + title: string + icon: React.ElementType + action: () => void + badge?: string + color: string + iconColor: string +} + +interface Props { + menuItems: MenuItem[] +} + +export default function MenuList({ menuItems }: Props) { + return ( + +

+ + 전체 메뉴 +

+
+ {menuItems.map((item, i) => ( +
+ +
+ ))} +
+
+ ) +} diff --git a/app/mypage/components/MypageHeader.tsx b/app/mypage/components/MypageHeader.tsx new file mode 100644 index 0000000..2a20dda --- /dev/null +++ b/app/mypage/components/MypageHeader.tsx @@ -0,0 +1,49 @@ +"use client" + +import { ArrowLeft, ChevronRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import { motion } from "framer-motion" +import { useRouter } from "next/navigation" + +interface Props { + user: { name: string; email: string; phoneNumber: string } +} + +export default function MyPageHeader({ user }: Props) { + const router = useRouter() + + return ( +
+
+ +

마이페이지

+
+
+ + router.push("/mypage/edit")} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + > +
+
+
+ {user.name.charAt(0)} +
+
+

{user.name}

+

{user.email}

+
+ +
+
+
+
+ ) +} diff --git a/app/mypage/edit/api/email.ts b/app/mypage/edit/api/email.ts new file mode 100644 index 0000000..77ca5f4 --- /dev/null +++ b/app/mypage/edit/api/email.ts @@ -0,0 +1,26 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export async function requestEmailVerification(email: string) { + const res = await fetch(`${API_URL}/api/users/emailCheck?email=${email}`, { + method: "POST", + credentials: "include", + }); + + if (!res.ok) throw new Error("이메일 인증 요청 실패"); + return res.json(); +} + +export async function verifyEmailCode(email: string, code: string) { + const res = await fetch(`${API_URL}/api/users/verification`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email, verification: code }), + }); + + if (!res.ok) throw new Error("인증번호 확인 실패"); + return res.json(); +} diff --git a/app/mypage/edit/api/update-user-info.ts b/app/mypage/edit/api/update-user-info.ts new file mode 100644 index 0000000..8111ebc --- /dev/null +++ b/app/mypage/edit/api/update-user-info.ts @@ -0,0 +1,21 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export async function updateUserInfo(data: { name: string; phoneNumber: string }) { + const res = await fetchWithAuth(`${API_URL}/api/users/info-update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(data), + }); + + if (!res.ok) { + throw new Error("회원정보 수정 실패"); + } + + return res.json(); +} diff --git a/app/mypage/edit/components/SuccessNotice.tsx b/app/mypage/edit/components/SuccessNotice.tsx new file mode 100644 index 0000000..ef238d0 --- /dev/null +++ b/app/mypage/edit/components/SuccessNotice.tsx @@ -0,0 +1,21 @@ +"use client" + +import { motion } from "framer-motion" +import { CheckCircle } from "lucide-react" + +export default function SuccessNotice() { + return ( + +
+ +
+

저장 완료!

+

프로필 정보가 성공적으로 업데이트되었습니다.

+

잠시 후 마이페이지로 이동합니다...

+
+ ) +} diff --git a/app/mypage/edit/components/card/ProfileCard.tsx b/app/mypage/edit/components/card/ProfileCard.tsx new file mode 100644 index 0000000..37d5146 --- /dev/null +++ b/app/mypage/edit/components/card/ProfileCard.tsx @@ -0,0 +1,29 @@ +"use client" + +import { Card } from "@/components/ui/card" +import { ReactNode } from "react" +import { motion } from "framer-motion" + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + }, + }, +} + +interface ProfileCardProps { + children: ReactNode +} + +export default function ProfileCard({ children }: ProfileCardProps) { + return ( + + + {children} + + + ) +} diff --git a/app/mypage/edit/components/card/ProfileCardHeader.tsx b/app/mypage/edit/components/card/ProfileCardHeader.tsx new file mode 100644 index 0000000..62b2977 --- /dev/null +++ b/app/mypage/edit/components/card/ProfileCardHeader.tsx @@ -0,0 +1,24 @@ +"use client" + +import { CardHeader, CardTitle } from "@/components/ui/card" +import { User } from "lucide-react" + +interface ProfileCardHeaderProps { + name: string +} + +export default function ProfileCardHeader({ name }: ProfileCardHeaderProps) { + return ( + + +
+ + 안녕하세요 {name}님!⚡️ +
+ + 회원 정보를 확인하고 필요한 내용을 수정해주세요. + +
+
+ ) +} diff --git a/app/mypage/edit/components/card/ProfileForm.tsx b/app/mypage/edit/components/card/ProfileForm.tsx new file mode 100644 index 0000000..ae9e6b0 --- /dev/null +++ b/app/mypage/edit/components/card/ProfileForm.tsx @@ -0,0 +1,187 @@ +"use client" + +import { Mail, Phone, User, Info } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { motion } from "framer-motion" +import { useState } from "react" + +interface ProfileFormProps { + user: { + name: string + email: string + phoneNumber: string + } + isSubmitting: boolean + onChange: (e: React.ChangeEvent) => void + onSubmit: (e: React.FormEvent) => void + onCancel: () => void +} + +const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + type: "spring", + stiffness: 100, + }, + }, +} + +// 전화번호 입력 시 자동 하이픈 포맷 +function formatPhoneNumber(value: string): string { + const onlyNums = value.replace(/\D/g, "").slice(0, 11); // 최대 11자리 제한 + + if (onlyNums.length < 4) return onlyNums; + if (onlyNums.length < 8) return `${onlyNums.slice(0, 3)}-${onlyNums.slice(3)}`; + return `${onlyNums.slice(0, 3)}-${onlyNums.slice(3, 7)}-${onlyNums.slice(7)}`; +} + +// 유효한 전화번호 형식인지 검사 +function isValidPhoneNumber(phone: string): boolean { + return /^010-\d{4}-\d{4}$/.test(phone); +} + +export default function ProfileForm({ + user, + isSubmitting, + onChange, + onSubmit, + onCancel, + }: ProfileFormProps) { + const [phoneError, setPhoneError] = useState(null) + + const handlePhoneChange = (e: React.ChangeEvent) => { + const formatted = formatPhoneNumber(e.target.value); + const isValid = isValidPhoneNumber(formatted); + setPhoneError(isValid ? null : "010-0000-0000 형식으로 입력해주세요."); + + const syntheticEvent = { + ...e, + target: { + ...e.target, + name: "phoneNumber", + value: formatted, + }, + } as React.ChangeEvent; + + onChange(syntheticEvent); + }; + + return ( +
+ {/* 이름 */} + +
+ + +
+
+ + {/* 이메일 */} + +
+ +
+ +
+

+ + 이메일은 변경이 불가능합니다. +

+
+
+ + {/* 전화번호 */} + +
+ + + {phoneError && ( +

{phoneError}

+ )} +
+
+ + {/* 버튼 */} + + + + +
+ ) +} diff --git a/app/mypage/edit/components/header/ProfileHeader.tsx b/app/mypage/edit/components/header/ProfileHeader.tsx new file mode 100644 index 0000000..53e7728 --- /dev/null +++ b/app/mypage/edit/components/header/ProfileHeader.tsx @@ -0,0 +1,28 @@ +"use client" + +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +export default function ProfileHeader() { + const router = useRouter() + + return ( + + +

프로필 수정

+
+ ) +} \ No newline at end of file diff --git a/app/mypage/edit/page.tsx b/app/mypage/edit/page.tsx new file mode 100644 index 0000000..b9e4e71 --- /dev/null +++ b/app/mypage/edit/page.tsx @@ -0,0 +1,90 @@ +"use client" + +import { useRouter } from "next/navigation" +import { useEffect, useState } from "react" +import ProfileHeader from "@/app/mypage/edit/components/header/ProfileHeader"; +import ProfileCard from "@/app/mypage/edit/components/card/ProfileCard"; +import ProfileCardHeader from "@/app/mypage/edit/components/card/ProfileCardHeader"; +import SuccessNotice from "@/app/mypage/edit/components/SuccessNotice"; +import ProfileForm from "@/app/mypage/edit/components/card/ProfileForm"; +import {getCookie} from "@/lib/cookies"; +import {getUserInfo} from "@/app/mypage/api/user-info"; +import {updateUserInfo} from "@/app/mypage/edit/api/update-user-info"; + + +export default function ProfileEditPage() { + const router = useRouter() + + // 상태 정의 + const [isSubmitting, setIsSubmitting] = useState(false) + const [user, setUser] = useState({ + name: "", + email: "", + phoneNumber: "", + }) + + const [isSaveSuccess, setIsSaveSuccess] = useState(false) + + useEffect(() => { + const token = getCookie("accessToken") + if (!token) return + + getUserInfo(token) + .then((data) => { + setUser(data) + }) + .catch((err) => { + console.error("유저 정보 불러오기 실패:", err) + }) + }, []) + + + // 이벤트 핸들러 + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setUser((prev) => ({ ...prev, [name]: value })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsSubmitting(true) + + try { + await updateUserInfo({ + name: user.name, + phoneNumber: user.phoneNumber, + }); + + setIsSaveSuccess(true) + setTimeout(() => router.push("/mypage"), 1500) + } catch (error) { + console.error("프로필 저장 실패:", error) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ +
+ + + {isSaveSuccess ? ( + + ) : ( + router.back()} + /> + )} + +
+
+
+ ) +} diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx new file mode 100644 index 0000000..48f677e --- /dev/null +++ b/app/mypage/page.tsx @@ -0,0 +1,82 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import {Wallet, Ticket, CreditCard, Bell, FileText, LockKeyholeOpen} from "lucide-react" +import MyPageHeader from "./components/MypageHeader" +import MenuList from "./components/MenuList" +import LogoutDialog from "./components/LogoutDialog" +import { getUserInfo, UserInfo } from "@/app/mypage/api/user-info"; +import { getCookie } from "@/lib/cookies"; + +export default function MyPage() { + const router = useRouter() + + const [user, setUser] = useState(null) + + useEffect(() => { + const token = getCookie("accessToken") + if (!token) return + + getUserInfo(token) + .then((data) => setUser(data)) + .catch((err) => { + console.error("유저 정보 요청 실패:", err) + }) + }, []) + + const menuItems = [ + { + title: "전자지갑", + icon: Wallet, + action: () => router.push("/wallet"), + color: "from-[#FFB020]/10 to-[#FF9500]/10", + iconColor: "text-[#FF9500]", + }, + { + title: "내 바우처", + icon: Ticket, + action: () => router.push("/my-vouchers"), + color: "from-[#10B981]/10 to-[#059669]/10", + iconColor: "text-[#10B981]", + }, + { + title: "비밀번호 변경", + icon: LockKeyholeOpen, + action: () => router.push("/mypage/change-password"), + color: "from-[#3B82F6]/10 to-[#2563EB]/10", + iconColor: "text-[#3B82F6]", + }, + { + title: "간편 비밀번호 변경", + icon: CreditCard, + action: () => router.push("/mypage/change-pin"), + color: "from-[#F43F5E]/10 to-[#D1365A]/10", + iconColor: "text-[#F43F5E]", + }, + { + title: "알림 설정", + icon: Bell, + action: () => router.push("/notifications/settings"), + color: "from-[#8B5CF6]/10 to-[#7C3AED]/10", + iconColor: "text-[#8B5CF6]", + }, + { + title: "공지사항", + icon: FileText, + action: () => router.push("/notice"), + color: "from-[#EC4899]/10 to-[#BE185D]/10", + iconColor: "text-[#EC4899]", + }, + ] + + return ( +
+ +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/app/mypage/reset-pin/api/reset-simple-password.ts b/app/mypage/reset-pin/api/reset-simple-password.ts new file mode 100644 index 0000000..bb2fde7 --- /dev/null +++ b/app/mypage/reset-pin/api/reset-simple-password.ts @@ -0,0 +1,15 @@ +import { getApiUrl } from "@/lib/getApiUrl" +import { fetchWithAuth } from "@/lib/fetchWithAuth" + +const API_URL = getApiUrl() + +// 임시 비밀번호 발송 요청 +export async function sendSimplePasswordResetEmail(email: string): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/find-simple-password?email=${encodeURIComponent(email)}`, { + method: "POST", + }) + + if (!res.ok) { + throw new Error("임시 비밀번호 발송에 실패했습니다.") + } +} diff --git a/app/mypage/reset-pin/components/ResetPinComplete.tsx b/app/mypage/reset-pin/components/ResetPinComplete.tsx new file mode 100644 index 0000000..62ffeb4 --- /dev/null +++ b/app/mypage/reset-pin/components/ResetPinComplete.tsx @@ -0,0 +1,36 @@ +"use client" + +import { CheckCircle } from "lucide-react" +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" + +interface Props { + email: string + onDone: () => void +} + +export default function ResetPinComplete({ email, onDone }: Props) { + return ( + + Security Mascot + +

이메일 발송 완료

+

+ {email}로 새로운 간편 비밀번호가 발송되었습니다. 이메일을 확인해주세요. +

+

+ 이메일이 도착하지 않았다면 스팸함을 확인하시거나 잠시 후 다시 시도해주세요. +

+ + +
+ ) +} diff --git a/app/mypage/reset-pin/components/ResetPinForm.tsx b/app/mypage/reset-pin/components/ResetPinForm.tsx new file mode 100644 index 0000000..e7c025c --- /dev/null +++ b/app/mypage/reset-pin/components/ResetPinForm.tsx @@ -0,0 +1,52 @@ +"use client" + +import { FormEvent } from "react" +import { Mail, Loader2 } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" + +interface Props { + email: string + setEmail: (email: string) => void + onSubmit: (e: FormEvent) => void + loading: boolean +} + +export default function ResetPinForm({ email, setEmail, onSubmit, loading }: Props) { + return ( +
+
+ + setEmail(e.target.value)} + placeholder="가입한 이메일을 입력하세요" + required + disabled={loading} + className="h-12 rounded-lg border-gray-300 focus:border-[#FFB020] focus:ring-[#FFB020]" + /> +
+ + +
+ ) +} diff --git a/app/mypage/reset-pin/components/ResetPinHeader.tsx b/app/mypage/reset-pin/components/ResetPinHeader.tsx new file mode 100644 index 0000000..cff7291 --- /dev/null +++ b/app/mypage/reset-pin/components/ResetPinHeader.tsx @@ -0,0 +1,21 @@ +"use client" + +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +export default function ResetPinHeader() { + const router = useRouter() + + return ( +
+
+ +

간편 비밀번호 찾기

+
+
+
+ ) +} diff --git a/app/mypage/reset-pin/page.tsx b/app/mypage/reset-pin/page.tsx new file mode 100644 index 0000000..ee645d1 --- /dev/null +++ b/app/mypage/reset-pin/page.tsx @@ -0,0 +1,109 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { toast } from "@/hooks/use-toast" +import Image from "next/image" +import ResetPinHeader from "@/app/mypage/reset-pin/components/ResetPinHeader" +import ResetPinForm from "@/app/mypage/reset-pin/components/ResetPinForm" +import ResetPinComplete from "@/app/mypage/reset-pin/components/ResetPinComplete" +import { sendSimplePasswordResetEmail } from "@/app/mypage/reset-pin/api/reset-simple-password" + +export default function ResetPinPage() { + const router = useRouter() + const [email, setEmail] = useState("") + const [loading, setLoading] = useState(false) + const [isComplete, setIsComplete] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!email || !email.includes("@")) { + toast({ + title: "이메일 오류", + description: "올바른 이메일 주소를 입력해주세요.", + variant: "destructive", + }) + return + } + + setLoading(true) + + try { + await sendSimplePasswordResetEmail(email) + + setIsComplete(true) + toast({ + title: "비밀번호 재설정 완료", + description: "새로운 간편 비밀번호가 이메일로 발송되었습니다.", + }) + } catch (error: any) { + toast({ + title: "전송 실패", + description: error?.message || "비밀번호 재설정 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setLoading(false) + } + } + + const handleComplete = () => { + router.push("/mypage") + } + + const pageVariants = { + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -10 }, + } + + return ( +
+ + +
+
+ {!isComplete ? ( + +
+ 비밀번호 찾기 +

간편 비밀번호 찾기

+

+ 가입하신 이메일을 입력하시면 새로운 간편 비밀번호를 이메일로 발송해 드립니다. +

+
+ + + +

+ * 이메일로 전송된 새 비밀번호로 로그인 후, 보안을 위해 비밀번호를 변경해주세요. +

+
+ ) : ( + + )} +
+
+
+ ) +} diff --git a/app/notice/[id]/page.tsx b/app/notice/[id]/page.tsx new file mode 100644 index 0000000..c8d5475 --- /dev/null +++ b/app/notice/[id]/page.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useParams, useSearchParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Calendar } from "lucide-react"; + +import SkeletonDetail from "@/app/notice/components/SkeletonDetail"; +import Header from "@/components/common/Header"; +import { fetchNoticeDetail, NoticeDetail } from "@/app/notice/api/notice-api"; + +export default function NoticeDetailPage() { + const { id } = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const noticeId = Array.isArray(id) ? id[0] : id; + const [notice, setNotice] = useState(null); + const [loading, setLoading] = useState(true); + + const page = searchParams.get("page") ?? "1"; + + useEffect(() => { + if (!noticeId) return; + + setLoading(true); + fetchNoticeDetail(noticeId) + .then(setNotice) + .catch((error) => { + console.error("Error fetching notice:", error); + }) + .finally(() => { + setLoading(false); + }); + }, [noticeId]); + + useEffect(() => { + const handlePopState = (event: PopStateEvent) => { + event.preventDefault(); + router.replace(`/notice?page=${page}`); + }; + + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, [page]); + + return ( +
+
+
+
+
+ {loading ? ( + + ) : notice ? ( + <> +
+
+ + {notice.createdAt?.slice(0, 10)} +
+
+ +

{notice.title}

+ +
+ {notice.content} +
+ + ) : ( +
+ 공지사항을 찾을 수 없습니다. +
+ )} +
+
+ ); +} diff --git a/app/notice/api/notice-api.ts b/app/notice/api/notice-api.ts new file mode 100644 index 0000000..92dd164 --- /dev/null +++ b/app/notice/api/notice-api.ts @@ -0,0 +1,58 @@ +import axios from "axios"; +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export interface Notice { + id: number; + title: string; + content: string; + createdAt: string; +} + +export interface NoticeDetail { + id: number; + title: string; + content: string; + createdAt: string; +} + +export interface FetchNoticeListResult { + content: Notice[]; + totalPages: number; +} + +export async function fetchNoticeList(currentPage: number): Promise { + const response = await fetchWithAuth(`${API_URL}/api/users/notice?page=${currentPage}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + + if (!response.ok) { + throw new Error("공지사항 목록을 불러오는 데 실패했습니다."); + } + + const data = await response.json(); + const result = typeof data.result === "string" ? JSON.parse(data.result) : data.result; + + return { + content: result.content || [], + totalPages: result.totalPages || 1, + }; +} + +export async function fetchNoticeDetail(id: string): Promise { + const response = await fetchWithAuth(`${API_URL}/api/users/notice/${id}`, { + method: "GET", + credentials: "include", + }); + + const data = await response.json(); + const result = typeof data.result === "string" ? JSON.parse(data.result) : data.result; + + return result; +} \ No newline at end of file diff --git a/app/notice/components/NoticeItem.tsx b/app/notice/components/NoticeItem.tsx new file mode 100644 index 0000000..2c5c646 --- /dev/null +++ b/app/notice/components/NoticeItem.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { ChevronRight } from "lucide-react"; + +export default function NoticeItem({ + notice, + isNew = false, + currentPage, +}: { + notice: any; + isNew?: boolean; + currentPage: number; +}) { + const router = useRouter(); + + const handleClick = () => { + router.push(`/notice/${notice.id}?page=${currentPage + 1}`); + }; + + return ( +
+
+
+

{notice.title}

+ {isNew && ( + + NEW + + )} +
+
+ {notice.createdAt?.slice(0, 10)} +
+
+ +
+ ); +} diff --git a/app/notice/components/NoticeList.tsx b/app/notice/components/NoticeList.tsx new file mode 100644 index 0000000..823ad52 --- /dev/null +++ b/app/notice/components/NoticeList.tsx @@ -0,0 +1,35 @@ +"use client" + +import NoticeItem from "@/app/notice/components/NoticeItem" + +interface Notice { + id: number + title: string + content: string + createdAt: string +} + +export default function NoticeList({ + notices, + latestNoticeIds = [], + currentPage, +}: { + notices: Notice[] + latestNoticeIds?: number[] + currentPage: number +}) { + return ( +
+
+ {notices.map((notice) => ( + + ))} +
+
+ ) +} diff --git a/app/notice/components/SkeletonDetail.tsx b/app/notice/components/SkeletonDetail.tsx new file mode 100644 index 0000000..49c3beb --- /dev/null +++ b/app/notice/components/SkeletonDetail.tsx @@ -0,0 +1,21 @@ +export default function SkeletonDetail() { + return ( +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/app/notice/components/SkeletonList.tsx b/app/notice/components/SkeletonList.tsx new file mode 100644 index 0000000..4828a96 --- /dev/null +++ b/app/notice/components/SkeletonList.tsx @@ -0,0 +1,23 @@ +import Header from "@/components/common/Header" + +export function SkeletonList() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 7 }).map((_, index) => ( +
+ ))} +
+
+
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+
+ ) +} diff --git a/app/notice/page.tsx b/app/notice/page.tsx new file mode 100644 index 0000000..894ccba --- /dev/null +++ b/app/notice/page.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useEffect, useState, Suspense } from "react" +import { useSearchParams, useRouter } from "next/navigation" + +import NoticeList from "@/app/notice/components/NoticeList" +import Pagination from "@/components/common/Pagination" +import { SkeletonList } from "@/app/notice/components/SkeletonList" +import Header from "@/components/common/Header" + +import { fetchNoticeList, type Notice } from "@/app/notice/api/notice-api" +import { filterLatestNoticeIds } from "@/lib/filterLatestNotices" + +function NoticesContent() { + const router = useRouter() + const searchParams = useSearchParams() + const currentPage = Number.parseInt(searchParams.get("page") ?? "1", 10) - 1 + + const [notices, setNotices] = useState([]) + const [totalPages, setTotalPages] = useState(1) + const [loading, setLoading] = useState(true) + const [latestNoticeIds, setLatestNoticeIds] = useState([]) + + useEffect(() => { + async function fetchData() { + setLoading(true) + try { + const { content, totalPages } = await fetchNoticeList(currentPage) + setNotices(content) + setTotalPages(totalPages) + + if (currentPage === 0) { + const latestIds = filterLatestNoticeIds(content) + setLatestNoticeIds(latestIds) + } + } catch (error) { + console.error("공지사항 불러오기 실패:", error) + } finally { + setLoading(false) + } + } + + fetchData() + }, [currentPage]) + + if (loading) { + return + } + + return ( +
+
+
+
+
+ +
+ +
+ +
+ router.push(`/notice?page=${page}`)} + /> +
+
+
+ ) +} + +export default function NoticesPage() { + return ( + Loading...
}> + + + ) +} diff --git a/app/notifications/api/delete-notifications.ts b/app/notifications/api/delete-notifications.ts new file mode 100644 index 0000000..cdebc8f --- /dev/null +++ b/app/notifications/api/delete-notifications.ts @@ -0,0 +1,12 @@ +"use client" + +import { fetchWithAuth } from "@/lib/fetchWithAuth" +import {getApiUrl} from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export async function deleteNotification(id: string): Promise { + await fetchWithAuth(`${API_URL}/api/users/notifications/${id}`, { + method: "DELETE", + }); +} \ No newline at end of file diff --git a/app/notifications/api/fetch-notifications-category.ts b/app/notifications/api/fetch-notifications-category.ts new file mode 100644 index 0000000..dc40097 --- /dev/null +++ b/app/notifications/api/fetch-notifications-category.ts @@ -0,0 +1,32 @@ +"use client" + +import { fetchWithAuth } from "@/lib/fetchWithAuth" +import {getApiUrl} from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export interface Notification { + id: string + type: "system" | "payment" | "token" + title: string + message: string + createdAt: string + deleted: "active" | "deleted" +} + +export async function fetchNotificationsByCategory(category: string): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/notifications/category?category=${category.toUpperCase()}`, { + method: "GET", + }) + + const json = await res.json() + + return json.result.map((n: any) => ({ + id: n.id, + type: n.category.toLowerCase(), + title: n.title, + message: n.content, + createdAt: n.createdAt, + deleted: n.deleted, + })) +} \ No newline at end of file diff --git a/app/notifications/api/fetch-notifications.ts b/app/notifications/api/fetch-notifications.ts new file mode 100644 index 0000000..59158d7 --- /dev/null +++ b/app/notifications/api/fetch-notifications.ts @@ -0,0 +1,29 @@ +"use client" + +import { fetchWithAuth } from "@/lib/fetchWithAuth" +import {getApiUrl} from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export interface Notification { + id: string + type: "system" | "payment" | "token" + title: string + message: string + createdAt: string + deleted: "active" | "deleted" +} + +export async function fetchNotifications(): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/notifications`, { method: "GET" }); + const json = await res.json(); + + return json.result.map((n: any) => ({ + id: n.id, + type: n.category.toLowerCase(), + title: n.title, + message: n.content, + createdAt: n.createdAt, + deleted: n.deleted, + })); +} diff --git a/app/notifications/components/NotificationEmptyState.tsx b/app/notifications/components/NotificationEmptyState.tsx new file mode 100644 index 0000000..f3abdf7 --- /dev/null +++ b/app/notifications/components/NotificationEmptyState.tsx @@ -0,0 +1,12 @@ +"use client" + +import { Bell } from "lucide-react" + +export default function NotificationEmptyState() { + return ( +
+ +

알림이 없습니다.

+
+ ) +} diff --git a/app/notifications/components/NotificationHeader.tsx b/app/notifications/components/NotificationHeader.tsx new file mode 100644 index 0000000..c6d9877 --- /dev/null +++ b/app/notifications/components/NotificationHeader.tsx @@ -0,0 +1,32 @@ +"use client" + +import { ArrowLeft, Settings } from "lucide-react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" + +export default function NotificationHeader() { + const router = useRouter() + + return ( +
+
+
+ +

알림

+
+
+ +
+
+
+ ) +} diff --git a/app/notifications/components/NotificationIcon.tsx b/app/notifications/components/NotificationIcon.tsx new file mode 100644 index 0000000..3eae9d3 --- /dev/null +++ b/app/notifications/components/NotificationIcon.tsx @@ -0,0 +1,16 @@ +import {AlertCircle, Bell, Coins, CreditCard} from "lucide-react" + +type NotificationType = "system" | "payment" | "token" + +export default function NotificationIcon({ type }: { type: NotificationType }) { + switch (type) { + case "system": + return + case "payment": + return + case "token": + return + default: + return + } +} diff --git a/app/notifications/components/NotificationItem.tsx b/app/notifications/components/NotificationItem.tsx new file mode 100644 index 0000000..920b1ac --- /dev/null +++ b/app/notifications/components/NotificationItem.tsx @@ -0,0 +1,61 @@ +"use client" + +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" +import { Trash2 } from "lucide-react" +import NotificationIcon from "./NotificationIcon" + +type NotificationType = "system" | "payment" | "token" + +interface Notification { + id: string + type: NotificationType + title: string + message: string + createdAt: string + deleted: "active" | "deleted" +} + +interface NotificationItemProps { + notification: Notification + onDelete: (id: string) => void +} + +export default function NotificationItem({ notification, onDelete }: NotificationItemProps) { + return ( + +
+
+ +
+
+

{notification.title}

+

{notification.message}

+

+ {new Date(notification.createdAt).toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +

+
+ +
+
+ ) +} diff --git a/app/notifications/components/NotificationList.tsx b/app/notifications/components/NotificationList.tsx new file mode 100644 index 0000000..1d53dc2 --- /dev/null +++ b/app/notifications/components/NotificationList.tsx @@ -0,0 +1,36 @@ +"use client" + +import { AnimatePresence } from "framer-motion" +import NotificationItem from "./NotificationItem" + +type NotificationType = "system" | "payment" | "token" + +interface Notification { + id: string + type: NotificationType + title: string + message: string + createdAt: string + deleted: "active" | "deleted" +} + +interface NotificationListProps { + notifications: Notification[] + onDelete: (id: string) => void +} + +export default function NotificationList({ notifications, onDelete }: NotificationListProps) { + return ( + +
+ {notifications.map((notification) => ( + + ))} +
+
+ ) +} diff --git a/app/notifications/components/NotificationSkeleton.tsx b/app/notifications/components/NotificationSkeleton.tsx new file mode 100644 index 0000000..2355eb6 --- /dev/null +++ b/app/notifications/components/NotificationSkeleton.tsx @@ -0,0 +1,17 @@ +"use client" + +import { Skeleton } from "@/components/ui/skeleton" + +export default function NotificationSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+ ) +} diff --git a/app/notifications/components/NotificationTabs.tsx b/app/notifications/components/NotificationTabs.tsx new file mode 100644 index 0000000..fc356f6 --- /dev/null +++ b/app/notifications/components/NotificationTabs.tsx @@ -0,0 +1,40 @@ +"use client" + +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" + +interface Category { + id: string + label: string +} + +interface NotificationTabsProps { + activeTab: string + onChange: (value: string) => void +} + +const categories: Category[] = [ + { id: "all", label: "전체" }, + { id: "system", label: "시스템" }, + { id: "payment", label: "결제" }, + { id: "token", label: "토큰" }, +] + +export default function NotificationTabs({ activeTab, onChange }: NotificationTabsProps) { + return ( +
+ + + {categories.map((category) => ( + + {category.label} + + ))} + + +
+ ) +} diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx new file mode 100644 index 0000000..e0ae923 --- /dev/null +++ b/app/notifications/page.tsx @@ -0,0 +1,127 @@ +"use client" + +import { useState, useEffect } from "react" +import { Bell, AlertCircle, CreditCard, Gift, Wallet } from "lucide-react" +import { useRouter } from "next/navigation" +import NotificationHeader from "@/app/notifications/components/NotificationHeader" +import NotificationTabs from "@/app/notifications/components/NotificationTabs" +import NotificationSkeleton from "@/app/notifications/components/NotificationSkeleton" +import NotificationEmptyState from "@/app/notifications/components/NotificationEmptyState" +import NotificationList from "@/app/notifications/components/NotificationList" +import { fetchNotifications } from "@/app/notifications/api/fetch-notifications" +import { fetchNotificationsByCategory } from "./api/fetch-notifications-category" +import { deleteNotification } from "@/app/notifications/api/delete-notifications" + +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" + +type NotificationType = "system" | "payment" | "token" + +interface Notification { + id: string + type: NotificationType + title: string + message: string + createdAt: string + deleted: "active" | "deleted" +} + +export default function NotificationsPage() { + const router = useRouter() + const [activeTab, setActiveTab] = useState("all") + const [notifications, setNotifications] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [openDialogId, setOpenDialogId] = useState(null) + + useEffect(() => { + const loadNotifications = async () => { + setIsLoading(true) + try { + if (activeTab === "all") { + const data = await fetchNotifications() + setNotifications(data) + } else { + const data = await fetchNotificationsByCategory(activeTab) + setNotifications(data) + } + } catch (e) { + console.error("알림 조회 실패", e) + } finally { + setIsLoading(false) + } + } + + loadNotifications() + }, [activeTab]) + + const confirmDeleteNotification = async () => { + if (!openDialogId) return + try { + await deleteNotification(openDialogId) + setNotifications((prev) => + prev.map((n) => + n.id === openDialogId ? { ...n, deleted: "deleted" } : n + ) + ) + } catch (error) { + console.error("알림 삭제 실패", error) + } finally { + setOpenDialogId(null) + } + } + + const filteredNotifications = notifications.filter((n) => { + if (n.deleted === "deleted") return false + if (activeTab === "all") return true + return n.type === activeTab + }) + + return ( +
+ + +
+ {isLoading ? ( + + ) : filteredNotifications.length === 0 ? ( + + ) : ( + setOpenDialogId(id)} + /> + )} +
+ + setOpenDialogId(null)}> + + + 알림을 삭제하시겠습니까? + + 알림을 삭제한 후에는 복구할 수 없습니다. + + + + + + + + +
+ ) +} diff --git a/app/notifications/settings/api/fetch-notification-setting.ts b/app/notifications/settings/api/fetch-notification-setting.ts new file mode 100644 index 0000000..93be701 --- /dev/null +++ b/app/notifications/settings/api/fetch-notification-setting.ts @@ -0,0 +1,19 @@ +"use client" + +import { fetchWithAuth } from "@/lib/fetchWithAuth" +import {getApiUrl} from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export interface NotificationCategorySetting { + category: "SYSTEM" | "PAYMENT" | "TOKEN" + enabled: boolean +} + +export async function fetchNotificationSettings(): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/notifications/setting`, { + method: "GET", + }) + const json = await res.json() + return json.result +} \ No newline at end of file diff --git a/app/notifications/settings/api/update-notification-setting.ts b/app/notifications/settings/api/update-notification-setting.ts new file mode 100644 index 0000000..47b379f --- /dev/null +++ b/app/notifications/settings/api/update-notification-setting.ts @@ -0,0 +1,18 @@ +"use client" + +import { fetchWithAuth } from "@/lib/fetchWithAuth" +import {getApiUrl} from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export interface NotificationCategorySetting { + category: "SYSTEM" | "PAYMENT" | "TOKEN" + enabled: boolean +} + +export async function updateNotificationSettings(settings: NotificationCategorySetting[]): Promise { + await fetchWithAuth(`${API_URL}/api/users/notifications/setting`, { + method: "PUT", + body: JSON.stringify(settings), + }); +} \ No newline at end of file diff --git a/app/notifications/settings/components/NotificationPolicyInfo.tsx b/app/notifications/settings/components/NotificationPolicyInfo.tsx new file mode 100644 index 0000000..526e6a5 --- /dev/null +++ b/app/notifications/settings/components/NotificationPolicyInfo.tsx @@ -0,0 +1,21 @@ +"use client" + +import {Clock7} from "lucide-react" + +export default function NotificationPolicyInfo() { + return ( +
+
+
+ +
+
+

알림 보관 정책

+

+ 알림은 30일 동안 보관되며, 이후에는 자동으로 삭제됩니다.
삭제된 알림은 복구할 수 없습니다. +

+
+
+
+ ) +} diff --git a/app/notifications/settings/components/NotificationSettingHeader.tsx b/app/notifications/settings/components/NotificationSettingHeader.tsx new file mode 100644 index 0000000..b7408ad --- /dev/null +++ b/app/notifications/settings/components/NotificationSettingHeader.tsx @@ -0,0 +1,22 @@ +"use client" + +import { ArrowLeft } from "lucide-react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" + +export default function NotificationSettingsHeader() { + const router = useRouter() + + return ( +
+
+
+ +

알림 설정

+
+
+
+ ) +} diff --git a/app/notifications/settings/components/NotificationSettingItem.tsx b/app/notifications/settings/components/NotificationSettingItem.tsx new file mode 100644 index 0000000..315fbcc --- /dev/null +++ b/app/notifications/settings/components/NotificationSettingItem.tsx @@ -0,0 +1,42 @@ +"use client" + +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" +import { LucideIcon } from "lucide-react" + +interface SettingItemProps { + icon: LucideIcon + iconColor: string + iconBg: string + title: string + description: string + checked: boolean + onChange: (checked: boolean) => void + ariaLabel: string +} + +export default function NotificationSettingItem({ + icon: Icon, + iconColor, + iconBg, + title, + description, + checked, + onChange, + ariaLabel, + }: SettingItemProps) { + return ( +
+
+
+ +
+
+

{title}

+

{description}

+
+
+ +
+ ) +} diff --git a/app/notifications/settings/components/NotificationSettingSkeleton.tsx b/app/notifications/settings/components/NotificationSettingSkeleton.tsx new file mode 100644 index 0000000..9392240 --- /dev/null +++ b/app/notifications/settings/components/NotificationSettingSkeleton.tsx @@ -0,0 +1,19 @@ +"use client" + +import { Skeleton } from "@/components/ui/skeleton" + +export default function NotificationSettingsSkeleton() { + return ( +
+ {[1, 2, 3, 4].map((i) => ( +
+
+ + +
+ +
+ ))} +
+ ) +} diff --git a/app/notifications/settings/page.tsx b/app/notifications/settings/page.tsx new file mode 100644 index 0000000..d151c74 --- /dev/null +++ b/app/notifications/settings/page.tsx @@ -0,0 +1,164 @@ +"use client" + +import { useEffect, useState } from "react" +import { CreditCard, Shield, Megaphone, Bell, AlertCircle, Wallet, Coins } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { toast } from "@/components/ui/use-toast" +import NotificationSettingsHeader from "@/app/notifications/settings/components/NotificationSettingHeader" +import NotificationSettingItem from "@/app/notifications/settings/components/NotificationSettingItem" +import NotificationSettingsSkeleton from "@/app/notifications/settings/components/NotificationSettingSkeleton" +import NotificationPolicyInfo from "@/app/notifications/settings/components/NotificationPolicyInfo" +import { fetchNotificationSettings } from "@/app/notifications/settings/api/fetch-notification-setting" +import { updateNotificationSettings } from "@/app/notifications/settings/api/update-notification-setting" +import LoadingOverlay from "@/components/common/LoadingOverlay"; + +interface NotificationSettings { + SYSTEM: boolean + PAYMENT: boolean + TOKEN: boolean +} + +export default function NotificationSettingsPage() { + const [settings, setSettings] = useState({ + SYSTEM: true, + PAYMENT: true, + TOKEN: true, + }) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + + useEffect(() => { + const load = async () => { + setIsLoading(true) + try { + const result = await fetchNotificationSettings() + const mapped = result.reduce((acc, cur) => { + acc[cur.category] = cur.enabled + return acc + }, {} as NotificationSettings) + setSettings(mapped) + } catch (e) { + console.error("알림 설정 불러오기 실패", e) + } finally { + setIsLoading(false) + } + } + + load() + }, []) + + const handleAllChange = (checked: boolean) => { + setSettings({ + SYSTEM: checked, + PAYMENT: checked, + TOKEN: checked, + }) + } + + const handleSettingChange = (category: keyof NotificationSettings, checked: boolean) => { + setSettings(prev => ({ ...prev, [category]: checked })) + } + + const saveSettings = async () => { + setIsSaving(true) + try { + const body = Object.entries(settings).map(([category, enabled]) => ({ + category: category as "SYSTEM" | "PAYMENT" | "TOKEN", + enabled, + })) + await updateNotificationSettings(body) + + toast({ + title: "설정이 저장되었습니다", + description: "알림 설정이 성공적으로 업데이트되었습니다.", + }) + } catch (e) { + console.error("저장 실패:", e) + toast({ + title: "설정 저장 실패", + description: "다시 시도해주세요.", + variant: "destructive", + }) + } finally { + setIsSaving(false) + } + } + + return ( +
+ + +
+
+ {isLoading ? ( + + ) : ( + <> + + + handleSettingChange("SYSTEM", c)} + ariaLabel="시스템 알림 설정" + /> + + handleSettingChange("PAYMENT", c)} + ariaLabel="결제 알림 설정" + /> + + handleSettingChange("TOKEN", c)} + ariaLabel="토큰 알림 설정" + /> + + )} +
+ + + +
+ +
+ {isSaving && } +
+
+ ) +} diff --git a/app/offline-stores/api/store-service.ts b/app/offline-stores/api/store-service.ts new file mode 100644 index 0000000..73292e3 --- /dev/null +++ b/app/offline-stores/api/store-service.ts @@ -0,0 +1,37 @@ +import axios from "axios" +import type {Store, StoreSearchParams} from "../types" +import {getApiUrl} from "@/lib/getApiUrl"; +import { getCookie } from "@/lib/cookies"; + +const accessToken = getCookie("accessToken"); + +const API_URL = getApiUrl() +/** + * 주변 매장 검색 함수 (axios 기반) + * @param params 검색 파라미터 + * @returns 매장 목록 + */ +export async function fetchNearbyStores(params: StoreSearchParams): Promise { + try { + + const response = await axios.get(`${API_URL}/api/users/store/nearby`, { + params, + headers: { + "Content-Type": "application/json", + "Authorization": accessToken ? `Bearer ${accessToken}` : "", + }, + }) + const data = response.data + + if (!data.isSuccess) { + throw new Error(data.message || "API 요청 실패") + } + + const stores = data.result || [] + + return stores + } catch (error) { + console.error("API 요청 중 오류 발생:", error) + throw error + } +} diff --git a/app/offline-stores/components/StoreCategoryFilter.tsx b/app/offline-stores/components/StoreCategoryFilter.tsx new file mode 100644 index 0000000..4ffe6c9 --- /dev/null +++ b/app/offline-stores/components/StoreCategoryFilter.tsx @@ -0,0 +1,48 @@ +"use client" + +import { motion, AnimatePresence } from "framer-motion" +import { Button } from "@/components/ui/button" +import { CATEGORIES } from "@/app/offline-stores/utils/map-utils" + +interface Props { + show: boolean + selectedCategory: string + setSelectedCategory: (category: string) => void +} + +export default function StoreCategoryFilter({ + show, + selectedCategory, + setSelectedCategory, + }: Props) { + return ( + + {show && ( + +
+ {CATEGORIES.map((category) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/app/offline-stores/components/StoreCategoryToggleButton.tsx b/app/offline-stores/components/StoreCategoryToggleButton.tsx new file mode 100644 index 0000000..6149cb4 --- /dev/null +++ b/app/offline-stores/components/StoreCategoryToggleButton.tsx @@ -0,0 +1,17 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { ChevronUp, ChevronDown } from "lucide-react" + +interface Props { + show: boolean + onToggle: () => void +} + +export default function StoreCategoryToggleButton({ show, onToggle }: Props) { + return ( + + ) +} \ No newline at end of file diff --git a/app/offline-stores/components/StoreItem.tsx b/app/offline-stores/components/StoreItem.tsx new file mode 100644 index 0000000..5aba86e --- /dev/null +++ b/app/offline-stores/components/StoreItem.tsx @@ -0,0 +1,39 @@ +"use client" + +import { MapPin } from "lucide-react" +import { Store } from "@/app/offline-stores/types" + +interface StoreItemProps { + store: Store + isSelected: boolean + onClick: () => void +} + +export default function StoreItem({ store, isSelected, onClick }: StoreItemProps) { + return ( +
+
+
+
+ + {store.storeCategory} + +
+

{store.name}

+

{store.roadAddress}

+
+
+ + + {(store.distance / 1000).toFixed(1)}km + +
+
+
+ ) +} diff --git a/app/offline-stores/components/StoreList.tsx b/app/offline-stores/components/StoreList.tsx new file mode 100644 index 0000000..ffc3ace --- /dev/null +++ b/app/offline-stores/components/StoreList.tsx @@ -0,0 +1,157 @@ +/* +"use client" + +import { AnimatePresence, motion } from "framer-motion" +import { Button } from "@/components/ui/button" +import { MapPin, X } from "lucide-react" +import { Store } from "@/app/offline-stores/types" +import StoreItem from "@/app/offline-stores/components/StoreItem" + +interface StoreListProps { + stores: Store[] + selectedStore: number | null + onSelect: (id: number) => void + onClose: () => void + show: boolean +} + +export default function StoreList({ + stores, + selectedStore, + onSelect, + onClose, + show, + }: StoreListProps) { + return ( + + {show && ( + +
+

매장 목록 ({stores.length})

+ +
+ + {stores.length === 0 ? ( +
+ +

검색 결과가 없습니다

+
+ ) : ( +
+ {stores.map((store) => ( + onSelect(store.id)} + /> + ))} +
+ )} +
+ )} +
+ ) +} +*/ +"use client" + +import { AnimatePresence, motion } from "framer-motion" +import { Button } from "@/components/ui/button" +import { MapPin, X } from "lucide-react" +import { Store } from "@/app/offline-stores/types" +import StoreItem from "@/app/offline-stores/components/StoreItem" +import { FixedSizeList as VirtualList, ListChildComponentProps } from "react-window" +import AutoSizer from "react-virtualized-auto-sizer" + +interface StoreListProps { + stores: Store[] + selectedStore: number | null + onSelect: (id: number) => void + onClose: () => void + show: boolean +} + +// VirtualList에서 각 아이템을 렌더링하는 함수 +function Row({ + data, + index, + style, + }: ListChildComponentProps<{ + stores: Store[] + selectedStore: number | null + onSelect: (id: number) => void +}>) { + const { stores, selectedStore, onSelect } = data + const store = stores[index] + + return ( +
+ onSelect(store.id)} + /> +
+ ) +} + +export default function StoreList({ + stores, + selectedStore, + onSelect, + onClose, + show, + }: StoreListProps) { + return ( + + {show && ( + +
+

매장 목록 ({stores.length})

+ +
+ + {stores.length === 0 ? ( +
+ +

검색 결과가 없습니다

+
+ ) : ( +
+ + {({ height, width }) => ( + + {Row} + + )} + +
+ )} +
+ )} +
+ ) +} diff --git a/app/offline-stores/components/StoreListToggleButton.tsx b/app/offline-stores/components/StoreListToggleButton.tsx new file mode 100644 index 0000000..2eec327 --- /dev/null +++ b/app/offline-stores/components/StoreListToggleButton.tsx @@ -0,0 +1,25 @@ +"use client" + +import { List } from "lucide-react" +import { Button } from "@/components/ui/button" + +interface Props { + showList: boolean + onToggle: () => void + disabled: boolean +} + +export default function StoreListToggleButton({ showList, onToggle, disabled }: Props) { + return ( +
+ +
+ ) +} diff --git a/app/offline-stores/components/StoreLocateButton.tsx b/app/offline-stores/components/StoreLocateButton.tsx new file mode 100644 index 0000000..7ff425e --- /dev/null +++ b/app/offline-stores/components/StoreLocateButton.tsx @@ -0,0 +1,26 @@ +"use client" + +import { Locate } from "lucide-react" +import { Button } from "@/components/ui/button" + +interface Props { + onClick: () => void + disabled: boolean +} + +export default function StoreLocateButton({ onClick, disabled }: Props) { + return ( +
+ +
+ ) +} diff --git a/app/offline-stores/components/StoreSearchButton.tsx b/app/offline-stores/components/StoreSearchButton.tsx new file mode 100644 index 0000000..759ffbf --- /dev/null +++ b/app/offline-stores/components/StoreSearchButton.tsx @@ -0,0 +1,23 @@ +"use client" + +import { RefreshCw } from "lucide-react" +import { Button } from "@/components/ui/button" + +interface Props { + onClick: () => void + disabled: boolean +} + +export default function StoreSearchButton({ onClick, disabled }: Props) { + return ( +
+ +
+ ) +} diff --git a/app/offline-stores/components/StoreSearchInput.tsx b/app/offline-stores/components/StoreSearchInput.tsx new file mode 100644 index 0000000..db8138a --- /dev/null +++ b/app/offline-stores/components/StoreSearchInput.tsx @@ -0,0 +1,42 @@ +"use client" + +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Search, X } from "lucide-react" + +interface Props { + searchTerm: string + setSearchTerm: (term: string) => void + onSearch: () => void +} + +export default function StoreSearchInput({ searchTerm, setSearchTerm, onSearch }: Props) { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSearch() + } + + return ( +
+ setSearchTerm(e.target.value)} + className="pl-10 pr-10 py-2 rounded-full" + /> + + {searchTerm && ( + + )} + + ) +} \ No newline at end of file diff --git a/app/offline-stores/page.tsx b/app/offline-stores/page.tsx new file mode 100644 index 0000000..939697c --- /dev/null +++ b/app/offline-stores/page.tsx @@ -0,0 +1,228 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" +import { fetchNearbyStores } from "./api/store-service" +import type { Store as StoreType, StoreSearchParams } from "./types" + +import { + loadKakaoMapScript, + initializeMap, + addCurrentLocationMarker, + updateMarkers, + calculateMapRadius, +} from "./utils/map-utils" + +import StoreSearchButton from "./components/StoreSearchButton" +import StoreListToggleButton from "./components/StoreListToggleButton" +import StoreLocateButton from "./components/StoreLocateButton" +import StoreCategoryFilter from "./components/StoreCategoryFilter" +import StoreCategoryToggleButton from "./components/StoreCategoryToggleButton" +import StoreSearchInput from "./components/StoreSearchInput" +import StoreList from "./components/StoreList" +import { generateOverlayContent, injectOverlayStyles } from "./utils/map-overlay" + +export type Store = StoreType + +export default function OfflineStoresPage() { + const router = useRouter() + const [searchTerm, setSearchTerm] = useState("") + const [selectedCategory, setSelectedCategory] = useState("전체") + const [stores, setStores] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null) + const [showFilters, setShowFilters] = useState(false) + const [showList, setShowList] = useState(false) + const [selectedStore, setSelectedStore] = useState(null) + const [isMapLoaded, setIsMapLoaded] = useState(false) + const [mapError, setMapError] = useState(null) + + const mapRef = useRef(null) + const markersRef = useRef([]) + const overlaysRef = useRef([]) + const currentLocationMarkerRef = useRef(null) + const mapBoundsRef = useRef(null) + const scriptLoadAttemptRef = useRef(0) + const mapContainerRef = useRef(null) + + useEffect(() => { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (pos) => { + setCurrentLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }) + }, + () => setCurrentLocation({ lat: 37.5066, lng: 127.0557 }) + ) + } + }, []) + + useEffect(() => { + if (currentLocation) { + loadKakaoMapScript(setIsMapLoaded, () => { + initializeMap( + mapRef, + currentLocation, + mapBoundsRef, + fetchStores, + () => addCurrentLocationMarker(mapRef, currentLocation, currentLocationMarkerRef), + 5 + ) + }, scriptLoadAttemptRef, setMapError) + } + }, [currentLocation]) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const mapEl = document.getElementById("map") + const clickedTarget = e.target as Node + + const ignoreSelectors = [ + ".custom-overlay", + ".store-list", + ".motion-div-filter", + ".leaflet-marker-icon", + "img[src*='marker']", + ] + + const shouldIgnore = ignoreSelectors.some((selector) => + (clickedTarget instanceof Element) && clickedTarget.closest(selector) + ) + + if (mapEl?.contains(clickedTarget) && !shouldIgnore) { + setShowFilters(false) + setShowList(false) + overlaysRef.current.forEach((overlay) => overlay.setMap(null)) + overlaysRef.current = [] + } + } + + document.addEventListener("click", handleClickOutside) + return () => document.removeEventListener("click", handleClickOutside) + }, []) + + const fetchStores = async (params: StoreSearchParams) => { + setIsLoading(true) + try { + const data = await fetchNearbyStores(params) + setStores(data) + updateMarkers(data, mapRef, markersRef, overlaysRef, setSelectedStore, createOverlay) + } catch { + setError("매장 정보를 불러오는데 실패했습니다.") + } finally { + setIsLoading(false) + } + } + const handleLocateUser = () => { + if (!navigator.geolocation) return + + navigator.geolocation.getCurrentPosition( + (pos) => { + const lat = pos.coords.latitude + const lng = pos.coords.longitude + + setCurrentLocation({ lat, lng }) // 상태도 업데이트 + if (mapRef.current) { + const center = new window.kakao.maps.LatLng(lat, lng) + mapRef.current.setCenter(center) + searchByMapArea() // 위치 이동 후 검색까지 자동 수행 + } + }, + () => { + alert("현재 위치를 가져올 수 없습니다.") + } + ) + } + const createOverlay = (store: Store, marker: any) => { + if (!mapRef.current) return + injectOverlayStyles() + const content = generateOverlayContent(store) + const position = marker.getPosition() + const overlay = new window.kakao.maps.CustomOverlay({ position, content, map: mapRef.current, zIndex: 99 }) + overlaysRef.current.push(overlay) + + const onOutsideClick = (e: MouseEvent) => { + const el = document.querySelector(".custom-overlay") + if (el && !el.contains(e.target as Node)) { + overlay.setMap(null) + setSelectedStore(null) + document.removeEventListener("click", onOutsideClick) + } + } + + setTimeout(() => { + const closeBtn = document.querySelector(".overlay-close-btn") + if (closeBtn) { + closeBtn.addEventListener("click", () => { + overlay.setMap(null) + setSelectedStore(null) + document.removeEventListener("click", onOutsideClick) + }) + } + document.addEventListener("click", onOutsideClick) + }, 100) + } + + const searchByMapArea = () => { + if (!mapRef.current) return + const center = mapRef.current.getCenter() + const bounds = mapRef.current.getBounds() + const radius = calculateMapRadius(bounds) || 3000 + fetchStores({ + lat: center.getLat(), + lng: center.getLng(), + radius, + storeCategory: selectedCategory !== "전체" ? selectedCategory : undefined, + keyword: searchTerm || undefined, + }) + } + + const handleStoreSelect = (id: number) => { + const store = stores.find((s) => s.id === id) + if (!store || !mapRef.current) return + setSelectedStore(id) + mapRef.current.setCenter(new window.kakao.maps.LatLng(store.latitude, store.longitude)) + overlaysRef.current.forEach((o) => o.setMap(null)) + overlaysRef.current = [] + const marker = markersRef.current.find((m: any) => m.storeId === store.id) + + if (marker) createOverlay(store, marker) + setShowList(false) + } + + return ( +
+
+
+
+ + +
+ setShowFilters(!showFilters)} /> +
+
+ + + +
+
+ + setShowList(!showList)} disabled={!isMapLoaded || !!mapError || isLoading} /> + + setShowList(false)} show={showList} /> +
+
+ ) +} diff --git a/app/offline-stores/types/index.ts b/app/offline-stores/types/index.ts new file mode 100644 index 0000000..b173e9a --- /dev/null +++ b/app/offline-stores/types/index.ts @@ -0,0 +1,22 @@ +// 매장 정보 타입 - 백엔드 응답 형식에 맞게 수정 +export interface Store { + id: number + name: string + roadAddress: string + newZipCode: string + latitude: number + longitude: number + distance: number + storeCategory: string +} + + +export interface StoreSearchParams { + lat: number + lng: number + radius: number + storeCategory?: string + keyword?: string +} + + diff --git a/app/offline-stores/types/kakao.d.ts b/app/offline-stores/types/kakao.d.ts new file mode 100644 index 0000000..6d77324 --- /dev/null +++ b/app/offline-stores/types/kakao.d.ts @@ -0,0 +1,83 @@ +declare global { + interface Window { + kakao: { + maps: { + LatLng: new ( + lat: number, + lng: number, + ) => { + getLat(): number + getLng(): number + } + Map: new ( + container: HTMLElement, + options: any, + ) => { + setCenter(position: any): void + setLevel(level: number): void + getLevel(): number + panTo(position: any): void + getCenter(): any + getBounds(): any + } + Marker: new ( + options: any, + ) => { + setMap(map: any): void + setPosition(position: any): void + setImage(markerImage: any): void + setZIndex(zIndex: number): void + } + MarkerImage: new (src: string, size: any, options?: any) => void + Size: new (width: number, height: number) => void + Point: new (x: number, y: number) => any + event: { + addListener(target: any, type: string, handler: Function): void + } + InfoWindow: new ( + options: any, + ) => { + open(map: any, marker: any): void + close(): void + setContent(content: string): void + setPosition(position: any): void + setZIndex(zIndex: number): void + } + CustomOverlay: new ( + options: any, + ) => { + setMap(map: any): void + setPosition(position: any): void + setContent(content: string): void + setZIndex(zIndex: number): void + } + services: { + Geocoder: new () => { + addressSearch(address: string, callback: (result: any[], status: any) => void): void + } + Status: { + OK: string + ZERO_RESULT: string + ERROR: string + } + } + MarkerClusterer: new ( + options: any, + ) => { + addMarkers(markers: any[]): void + clear(): void + setMinLevel(level: number): void + } + load(callback: () => void): void + } + } + } +} +declare global { + namespace kakao.maps { + interface Marker { + storeId?: number + } + } +} +export {} diff --git a/app/offline-stores/utils/map-overlay.ts b/app/offline-stores/utils/map-overlay.ts new file mode 100644 index 0000000..9c36516 --- /dev/null +++ b/app/offline-stores/utils/map-overlay.ts @@ -0,0 +1,100 @@ +// utils/map-overlay.ts + +/** + * HTML 오버레이 콘텐츠 생성 + */ +export function generateOverlayContent(store: { + storeCategory: string; + name: string; + roadAddress: string; + distance: number; +}): string { + return ` +
+
+
${store.storeCategory}
+
${store.name}
+
${store.roadAddress}
+
${(store.distance / 1000).toFixed(1)}km
+ +
+
+
+ `; +} + +/** + * 오버레이 스타일 삽입 + */ +export function injectOverlayStyles(): void { + const existing = document.getElementById("custom-overlay-style"); + if (existing) return; + const style = document.createElement("style"); + style.id = "custom-overlay-style"; + style.textContent = ` + .custom-overlay { + position: relative; + bottom: 85px; + border-radius: 8px; + float: left; + max-width: 330px; + word-wrap: break-word; + } + .overlay-content { + padding: 10px; + border-radius: 8px; + background: white; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + } + .overlay-category { + display: inline-block; + font-size: 11px; + color: #4F6EF7; + background: rgba(79, 110, 247, 0.1); + padding: 2px 6px; + border-radius: 10px; + margin-bottom: 4px; + } + .overlay-name { + font-weight: bold; + font-size: 14px; + margin-bottom: 4px; + } + .overlay-address { + font-size: 12px; + color: #666; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 240px; + } + .overlay-distance { + font-size: 12px; + color: #4F6EF7; + font-weight: bold; + } + .overlay-close-btn { + position: absolute; + top: 5px; + right: 5px; + background: none; + border: none; + font-size: 16px; + cursor: pointer; + color: #999; + } + .overlay-arrow { + position: absolute; + bottom: -8px; + left: 50%; + margin-left: -8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid white; + } + `; + document.head.appendChild(style); +} diff --git a/app/offline-stores/utils/map-utils.ts b/app/offline-stores/utils/map-utils.ts new file mode 100644 index 0000000..ad3eeb0 --- /dev/null +++ b/app/offline-stores/utils/map-utils.ts @@ -0,0 +1,170 @@ +// utils/map-utils.ts +import type { Store, StoreSearchParams } from "../types" +interface MarkerWithStoreId extends kakao.maps.Marker { + storeId?: number +} +export const CATEGORIES = ["전체", "음식점", "의료", "서비스", "관광", "숙박", "교육"] +export const loadKakaoMapScript = ( + setIsMapLoaded: (val: boolean) => void, + initializeMap: () => void, + scriptLoadAttemptRef: React.MutableRefObject, + setMapError: (msg: string | null) => void +) => { + const apiKey = process.env.NEXT_PUBLIC_KAKAO_MAP_API_KEY + scriptLoadAttemptRef.current++ + + if (window.kakao && window.kakao.maps) { + setIsMapLoaded(true) + initializeMap() + return + } + + const existing = document.getElementById("kakao-map-script") + if (existing) existing.remove() + + const script = document.createElement("script") + script.id = "kakao-map-script" + script.src = `${window.location.protocol}//dapi.kakao.com/v2/maps/sdk.js?appkey=${apiKey}&libraries=services,clusterer,drawing&autoload=false` + script.async = true + script.defer = true + + script.onload = () => { + window.kakao.maps.load(() => { + setIsMapLoaded(true) + initializeMap() + }) + } + + script.onerror = () => { + setMapError(`카카오맵 로드 실패 (시도 ${scriptLoadAttemptRef.current})`) + } + + document.head.appendChild(script) +} + +export const initializeMap = ( + mapRef: any, + currentLocation: { lat: number; lng: number }, + mapBoundsRef: React.MutableRefObject, // ← 타입 변경 + fetchStores: (params: StoreSearchParams) => void, + addCurrentLocationMarker: () => void, + DEFAULT_ZOOM_LEVEL: number +) => { + if (!window.kakao || !window.kakao.maps || !currentLocation) return + + const container = document.getElementById("map") + if (!container) return + + const options = { + center: new window.kakao.maps.LatLng(currentLocation.lat, currentLocation.lng), + level: DEFAULT_ZOOM_LEVEL, + } + + mapRef.current = new window.kakao.maps.Map(container, options) + + window.kakao.maps.event.addListener(mapRef.current, "bounds_changed", () => { + mapBoundsRef.current = mapRef.current.getBounds() + }) + + + const bounds = mapRef.current.getBounds() + fetchStores({ + lat: currentLocation.lat, + lng: currentLocation.lng, + radius: calculateMapRadius(bounds), + }) + + addCurrentLocationMarker() +} + + +export const addCurrentLocationMarker = ( + mapRef: any, + currentLocation: { lat: number; lng: number }, + currentLocationMarkerRef: any +) => { + if (!mapRef.current || !currentLocation) return + + if (currentLocationMarkerRef.current) { + currentLocationMarkerRef.current.setMap(null) + } + + const markerImage = new window.kakao.maps.MarkerImage( + "/images/current-location-marker.png", + new window.kakao.maps.Size(40, 40), + { offset: new window.kakao.maps.Point(20, 40) } + ) + + const position = new window.kakao.maps.LatLng(currentLocation.lat, currentLocation.lng) + const marker = new window.kakao.maps.Marker({ + position, + map: mapRef.current, + image: markerImage, + zIndex: 10, + }) + + currentLocationMarkerRef.current = marker +} + +export const updateMarkers = ( + stores: Store[], + mapRef: any, + markersRef: any, + overlaysRef: any, + setSelectedStore: (id: number | null) => void, + createOverlay: (store: Store, marker: any) => void +) => { + if (!mapRef.current || !window.kakao) return + markersRef.current.forEach((m: any) => m.setMap(null)) + overlaysRef.current.forEach((o: any) => o.setMap(null)) + markersRef.current = [] + overlaysRef.current = [] + + const markerImage = new window.kakao.maps.MarkerImage( + "/images/tokkit-marker.png", + new window.kakao.maps.Size(40, 40), + { offset: new window.kakao.maps.Point(20, 40) } + ) + + stores.forEach((store) => { + const position = new window.kakao.maps.LatLng(store.latitude, store.longitude) + const marker = new window.kakao.maps.Marker({ position, map: mapRef.current, image: markerImage }) as MarkerWithStoreId + marker.storeId = store.id // Store the store ID in the marker + + window.kakao.maps.event.addListener(marker, "click", () => { + setSelectedStore(store.id) + + const yOffset = 100 + const projection = mapRef.current.getProjection() + const point = projection.containerPointFromCoords(position) + const adjusted = new window.kakao.maps.Point(point.x, point.y - yOffset) + const newCenter = projection.coordsFromContainerPoint(adjusted) + mapRef.current.setCenter(newCenter) + + overlaysRef.current.forEach((o: any) => o.setMap(null)) + overlaysRef.current = [] + createOverlay(store, marker) + }) + + markersRef.current.push(marker) + }) +} + +export const calculateMapRadius = (bounds: any): number => { + if (!bounds) return 3000 + try { + const sw = bounds.getSouthWest() + const ne = bounds.getNorthEast() + + const latDiff = ne.getLat() - sw.getLat() + const lngDiff = ne.getLng() - sw.getLng() + + const latDistance = latDiff * 111000 + const lngDistance = lngDiff * 88740 + + const radius = Math.sqrt(Math.pow(latDistance, 2) + Math.pow(lngDistance, 2)) / 2 + return Math.round(radius) + } catch { + return 3000 + } +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..9cedd99 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,42 @@ +"use client" + +import { useState, useEffect } from "react" +import { useAnimation } from "framer-motion" +import LandingMain from "@/app/landing-page/components/LandingMain" +import CBDCTab from "@/app/landing-page/components/CBDCTab" +import { cardInfoData } from "@/app/landing-page/data/CardInfoData" + +export default function Home() { + // 상태 관리 + const [isTabOpen, setIsTabOpen] = useState(false) + const [currentCard, setCurrentCard] = useState(0) + + // 애니메이션 컨트롤 + const mainControls = useAnimation() + const tabControls = useAnimation() + + // 탭 전환 애니메이션 + useEffect(() => { + if (isTabOpen) { + mainControls.start({ x: "-100%" }) + tabControls.start({ x: 0 }) + } else { + mainControls.start({ x: 0 }) + tabControls.start({ x: "100%" }) + } + }, [isTabOpen, mainControls, tabControls]) + + // 컴포넌트 조립 + return ( +
+ setIsTabOpen(true)} isTabOpen={isTabOpen} /> + setIsTabOpen(false)} + currentCard={currentCard} + setCurrentCard={setCurrentCard} + cardInfo={cardInfoData} + /> +
+ ) +} diff --git a/app/payment/api/payment.ts b/app/payment/api/payment.ts new file mode 100644 index 0000000..a82545d --- /dev/null +++ b/app/payment/api/payment.ts @@ -0,0 +1,125 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; +import { getCookie } from "@/lib/cookies"; +const API_URL = getApiUrl(); + +export interface PaymentOptionResponse { + type: "TOKEN" | "VOUCHER"; + voucherOwnershipId?: number; + name: string; + balance: number; + expireDate: string; + usable: boolean; + storeCategory: string; +} + +export interface StoreInfoResponse { + storeId: number; + merchantId: number; + storeName: string; + address: string; + merchantName: string; +} + +// 가맹점 조회 +export async function fetchStoreInfo(storeId: number, merchantId: number) { + const url = new URL(`${API_URL}/api/users/store/info`); + url.searchParams.append("storeId", storeId.toString()); + url.searchParams.append("merchantId", merchantId.toString()); + + const res = await fetchWithAuth(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + throw new Error("가맹점 정보를 불러오지 못했습니다."); + } + + const data = await res.json(); + return data.result; +} + + +// 결제 수단 조회 +export async function getPaymentOptions(storeId: number): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/wallet/payment-options?storeId=${storeId}`, { + method: "GET", + }); + + if (!res.ok) throw new Error("결제 수단 목록을 불러오지 못했습니다."); + const json = await res.json(); + return json.result; +} + + +// 간편 비밀번호 검증 +export async function verifySimplePassword(simplePassword: string): Promise { + + const res = await fetchWithAuth(`${API_URL}/api/users/simple-password/verify`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ simplePassword }), + }); + + return res.ok; +} + +// 토큰 결제 +export async function submitTokenPayment( + merchantId: number, + amount: number, + simplePassword: string, + idempotencyKey: string, +) { + + const res = await fetchWithAuth(`${API_URL}/api/users/wallet/pay-with-token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": idempotencyKey, + }, + body: JSON.stringify({ + merchantId: Number(merchantId), + amount, + simplePassword, + }), + }); + + console.log(idempotencyKey, res.status, res.statusText, res.headers.get("Idempotency-Key")) + + return await res.json(); +} + +// 바우처 결제 +export async function submitVoucherPayment( + voucherOwnershipId: number, + merchantId: number, + storeId: number, + amount: number, + simplePassword: string, + idempotencyKey: string, +) { + const res = await fetchWithAuth(`${API_URL}/api/users/wallet/pay-with-voucher`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": idempotencyKey, + }, + body: JSON.stringify({ + voucherOwnershipId: Number(voucherOwnershipId), + merchantId: Number(merchantId), + storeId: Number(storeId), + simplePassword, + amount, + }), + }); + + console.log(idempotencyKey, res.status, res.statusText, res.headers.get("Idempotency-Key")) + + return await res.json(); +} diff --git a/app/payment/components/AmountBox.tsx b/app/payment/components/AmountBox.tsx new file mode 100644 index 0000000..7e8f003 --- /dev/null +++ b/app/payment/components/AmountBox.tsx @@ -0,0 +1,73 @@ +import AmountInput from "@/components/common/AmountInput"; +import { Voucher } from "@/app/merchant/mypage/qr-code/data/payment"; + +interface Props { + amount: string; + setAmount: (val: string) => void; + currentBalance: number; + selectedVoucher: Voucher; + onCancel: () => void; + onSubmit: () => void; + children?: React.ReactNode; +} + +export default function AmountBox({ + amount, + setAmount, + currentBalance, + selectedVoucher, + onCancel, + onSubmit, + children, +}: Props) { + const numericAmount = Number(amount); + const exceeded = numericAmount > currentBalance; + + return ( +
+ {children &&
{children}
} +

결제 금액 입력

+
+ setAmount(String(currentBalance))} + label="결제 금액" + bottomRightText={ + numericAmount > 0 && ( +

+ {exceeded + ? `최대 ${currentBalance.toLocaleString()}원까지 결제 가능합니다.` + : `결제 후 잔액: ${( + currentBalance - numericAmount + ).toLocaleString()}원`} +

+ ) + } + /> +
+ + +
+
+
+ ); +} diff --git a/app/payment/components/ManualBox.tsx b/app/payment/components/ManualBox.tsx new file mode 100644 index 0000000..edd3dde --- /dev/null +++ b/app/payment/components/ManualBox.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +interface ManualBoxProps { + onSubmit: (transactionId: string) => void; + onCancel: () => void; +} + +export default function ManualBox({ onSubmit, onCancel }: ManualBoxProps) { + const [input, setInput] = useState(""); + const [error, setError] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const trimmed = input.trim(); + if (!trimmed) { + setError("거래번호를 입력해주세요."); + return; + } + + onSubmit(trimmed); + }; + + return ( +
+
+

+ 거래번호 직접 입력 +

+

+ 가맹점에 비치된 QR 안내판 아래의{" "} + 코드를 입력해주세요. +

+ +
+ 거래번호 예시 +
+ + { + setInput(e.target.value); + if (error) setError(""); + }} + className="w-full h-12 px-4 border border-gray-300 rounded-lg text-base" + /> + {error &&

{error}

} +
+ +
+ + +
+
+ ); +} diff --git a/app/payment/components/MerchantInfoCard.tsx b/app/payment/components/MerchantInfoCard.tsx new file mode 100644 index 0000000..31c9fc2 --- /dev/null +++ b/app/payment/components/MerchantInfoCard.tsx @@ -0,0 +1,21 @@ +import Image from "next/image"; +import { Store } from "lucide-react"; + +interface Props { + name: string; + address: string; +} + +export default function MerchantInfoBox({ name, address}: Props) { + return ( +
+

가맹점 정보

+
+
+

{name}

+

{address}

+
+
+
+ ); +} diff --git a/app/payment/components/PaymentCard.tsx b/app/payment/components/PaymentCard.tsx new file mode 100644 index 0000000..fc05d35 --- /dev/null +++ b/app/payment/components/PaymentCard.tsx @@ -0,0 +1,80 @@ +import React from "react"; + +interface PaymentCardProps { + voucher: { + id: string; + icon: React.ReactNode; + title: string; + balance: number; + expiryDate: string; + disabled?: boolean; + }; + + isSelected: boolean; + onClick?: () => void; + fullWidth?: boolean; +} + +export default function PaymentCard({ + voucher, + isSelected, + onClick, + fullWidth = false, +}: PaymentCardProps) { + const isToken = voucher.id === "token"; + + return ( +
+
+
+ + {voucher.icon} + +

+ {voucher.title} +

+
+
+
+

잔액

+

+ {voucher.balance.toLocaleString()}원 +

+
+
+

만료일

+

{voucher.expiryDate}

+
+
+
+
+ ); +} diff --git a/app/payment/components/PaymentCarousel.tsx b/app/payment/components/PaymentCarousel.tsx new file mode 100644 index 0000000..d89f5f6 --- /dev/null +++ b/app/payment/components/PaymentCarousel.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { Voucher } from "@/app/merchant/mypage/qr-code/data/payment"; + +interface PaymentCarouselProps { + vouchers: Voucher[]; + currentIndex: number; + selectedIndex: number | null; + onScrollIndexChange: (index: number) => void; + onSelect: (index: number) => void; +} + +const CARD_WIDTH_PX = 250; +const CONTAINER_WIDTH_PX = 250; + +export default function PaymentCarousel({ + vouchers, + currentIndex, + selectedIndex, + onScrollIndexChange, + onSelect, +}: PaymentCarouselProps) { + const handleNext = () => { + const nextIndex = (currentIndex + 1) % vouchers.length; + onScrollIndexChange(nextIndex); + }; + + const handlePrev = () => { + const prevIndex = currentIndex === 0 ? vouchers.length - 1 : currentIndex - 1; + onScrollIndexChange(prevIndex); + }; + + return ( +
+

결제 수단 선택

+ + {/* 좌우 화살표 */} + + + + +
+ + {vouchers.map((voucher, index) => { + const isActive = currentIndex === index; + + return ( +
!voucher.disabled && onSelect(index)} + > + +
+ {voucher.icon} +

{voucher.title}

+
+ +
+
+

잔액

+

+ {voucher.balance.toLocaleString()}원 +

+
+ {voucher.title !== "토큰으로 결제" && ( +
+

만료일

+

{voucher.expiryDate}

+
+ )} +
+
+
+ ); + })} +
+
+ + {/* 인디케이터 */} +
+ {vouchers.map((_, index) => ( +
onScrollIndexChange(index)} + /> + ))} +
+
+ ); +} diff --git a/app/payment/components/QRScanBox.tsx b/app/payment/components/QRScanBox.tsx new file mode 100644 index 0000000..bee155f --- /dev/null +++ b/app/payment/components/QRScanBox.tsx @@ -0,0 +1,47 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useEffect } from "react"; + +const QRScanner = dynamic(() => import("@/components/qr-scanner"), { + ssr: false, + loading: () => ( +
+

카메라 로딩 중...

+
+ ), +}); + +interface QRScanBoxProps { + onScan: (value: string) => void; + scannerEnabled: boolean; +} + +export default function QRScanBox({ onScan }: QRScanBoxProps) { + useEffect(() => { + const video = document.querySelector("video"); + return () => { + if (video && video.srcObject) { + const stream = video.srcObject as MediaStream; + stream.getTracks().forEach((track) => track.stop()); + } + }; + }, []); + + return ( +
+

QR 코드 스캔

+

+ 가맹점 QR 코드를 스캔해주세요. +

+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/app/payment/components/ResultBox.tsx b/app/payment/components/ResultBox.tsx new file mode 100644 index 0000000..72b684a --- /dev/null +++ b/app/payment/components/ResultBox.tsx @@ -0,0 +1,78 @@ +import { Voucher } from "@/app/merchant/mypage/qr-code/data/payment"; +import {StoreInfoResponse} from "@/app/payment/api/payment"; + +interface ResultBoxProps { + paymentAmount: string; + storeQRInfo: StoreInfoResponse | null; + selectedVoucher: Voucher; +} + +export default function ResultBox({ + paymentAmount, + storeQRInfo, + selectedVoucher, +}: ResultBoxProps) { + const isToken = selectedVoucher.id === "token"; + const remaining = selectedVoucher.balance; + + return ( +
+
+
+ 결제수단 + + {isToken ? "토큰" : selectedVoucher.title} + +
+ +
+ 가맹점 + + {storeQRInfo?.merchantName || "-"} + +
+ +
+ 결제 일시 + + {new Date() + .toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }) + .replace(/\. /g, ".") + .replace(",", " ")} + +
+ +
+
+ 결제 금액 + + {Number(paymentAmount).toLocaleString()}원 + +
+ +
+ + {isToken ? "토큰 잔액" : "바우처 잔액"} + + + {remaining.toLocaleString()}원 + +
+
+
+
+ ); +} diff --git a/app/payment/components/VerifySimplePassword.tsx b/app/payment/components/VerifySimplePassword.tsx new file mode 100644 index 0000000..5e87ac1 --- /dev/null +++ b/app/payment/components/VerifySimplePassword.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import VirtualKeypad from "@/components/virtual-keypad"; + +interface Props { + onVerified: (password: string) => void; + disabled?: boolean; +} + +export default function VerifySimplePassword({ onVerified, disabled = false }: Props) { + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleKeypadComplete = async (pinCode: string) => { + setIsLoading(true); + setError(null); + + try { + setPassword(pinCode); + onVerified(pinCode); + } catch (e) { + setError("처리 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const reset = () => { + setPassword(""); + setError(null); + }; + + return ( +
+ +

간편 비밀번호 입력

+
+ + + + + {error && ( + + +

{error}

+
+ )} +
+ + {error && ( + + + + )} +
+ ); +} diff --git a/app/payment/page.tsx b/app/payment/page.tsx new file mode 100644 index 0000000..94a6423 --- /dev/null +++ b/app/payment/page.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { AnimatePresence, motion } from "framer-motion"; +import { LoaderCircle, CheckCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { myVouchers, Voucher } from "@/app/merchant/mypage/qr-code/data/payment"; +import confetti from "canvas-confetti"; + +import Header from "@/components/common/Header"; +import QRScanBox from "@/app/payment/components/QRScanBox"; +import ManualBox from "@/app/payment/components/ManualBox"; +import PaymentCarousel from "@/app/payment/components/PaymentCarousel"; +import MerchantInfoCard from "@/app/payment/components/MerchantInfoCard"; +import AmountBox from "@/app/payment/components/AmountBox"; +import ResultBox from "@/app/payment/components/ResultBox"; +import { + verifySimplePassword, + submitVoucherPayment, + submitTokenPayment, + getPaymentOptions, + fetchStoreInfo, + StoreInfoResponse, +} from "@/app/payment/api/payment"; +import VerifySimplePassword from "@/app/payment/components/VerifySimplePassword"; +import LoadingOverlay from "@/components/common/LoadingOverlay"; + +export default function PaymentPage() { + const router = useRouter(); + const [carouselIndex, setCarouselIndex] = useState(0); + const [showScanner, setShowScanner] = useState(true); + const [paymentStep, setPaymentStep] = useState< + "scan" | "amount" | "manual" | "password" | "result" + >("scan"); + const [transactionId, setTransactionId] = useState(""); + const [merchantInfo, setMerchantInfo] = useState(null); + const [usableVouchers, setUsableVouchers] = useState(myVouchers); + const [scannerKey, setScannerKey] = useState(0); + const [scanLocked, setScanLocked] = useState(false); + const [done, setDone] = useState(false); + + const [voucherOwnershipId, setVoucherOwnershipId] = useState(""); + const [merchantId, setMerchantId] = useState(0); + const [storeId, setStoreId] = useState(0); + const [paymentAmount, setPaymentAmount] = useState(""); + const [simplePassword, setSimplePassword] = useState(""); + + const [isProcessing, setIsProcessing] = useState(false); // 결제 요청 중 + const [isLoading, setIsLoading] = useState(false); // 매장 정보 등 로딩 중 + + useEffect(() => { + setPaymentAmount(""); + }, [carouselIndex]); + + useEffect(() => { + if (paymentStep === "result") { + setDone(false); + const timer = setTimeout(() => { + setDone(true); + }, 2000); + + return () => clearTimeout(timer); + } + }, [paymentStep]); + + useEffect(() => { + if (!done) return; + + const duration = 3 * 1000; + const animationEnd = Date.now() + duration; + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 }; + + function randomInRange(min: number, max: number) { + return Math.random() * (max - min) + min; + } + + const interval: NodeJS.Timeout = setInterval(() => { + const timeLeft = animationEnd - Date.now(); + + if (timeLeft <= 0) { + return clearInterval(interval); + } + + const particleCount = 50 * (timeLeft / duration); + + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, + }); + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, + }); + }, 450); + + return () => clearInterval(interval); + }, [done]); + + function parseTransactionId(txId: string): { merchantId: number, storeId: number } | null { + const match = txId.match(/^m(\d+)s(\d+)$/); + if (!match) return null; + const [, merchantId, storeId] = match; + return { merchantId: Number(merchantId), storeId: Number(storeId) }; + } + + const handleScanComplete = async (data: string) => { + if (scanLocked || isLoading) return; + setScanLocked(true); + setIsLoading(true); + + try { + const parsed = parseTransactionId(data); + if (!parsed) throw new Error("QR 형식 오류"); + + const { merchantId, storeId } = parsed; + + const storeInfo: StoreInfoResponse = await fetchStoreInfo(storeId, merchantId); + setMerchantInfo(storeInfo); + setMerchantId(merchantId); + setStoreId(storeId); + setTransactionId(data); + + const options = await getPaymentOptions(storeId); + const mapped = options.map((opt) => ({ + id: opt.type === "TOKEN" ? "token" : String(opt.voucherOwnershipId), + title: opt.name, + balance: opt.balance, + expiryDate: opt.expireDate, + icon: opt.type === "TOKEN" ? "🪙" : "🎟️", + disabled: !opt.usable, + })); + + setUsableVouchers(mapped); + + setTimeout(() => { + setPaymentStep("amount"); + setIsLoading(false); + setScanLocked(false); + }, 800); + } catch (err) { + console.error("QR 인식 실패:", err); + alert("QR 코드가 잘못되었거나 해당 매장을 찾을 수 없습니다."); + setShowScanner(false); + setTimeout(() => { + setScannerKey((prev) => prev + 1); + setShowScanner(true); + setScanLocked(false); + setIsLoading(false); + }, 600); + } + }; + + const handleManualEntry = () => { + setPaymentStep("manual"); + }; + + const handleCancel = () => { + setPaymentStep("scan"); + setPaymentAmount(""); + setTransactionId(""); + setMerchantInfo(null); + setCarouselIndex(0); + setShowScanner(true); + }; + + const handleSubmitTransaction = async (input: string) => { + if (isLoading) return; + const trimmed = input.trim(); + const parsed = parseTransactionId(trimmed); + if (!parsed) { + alert("거래번호 형식이 잘못되었습니다."); + return; + } + + try { + setIsLoading(true); + const { merchantId, storeId } = parsed; + const storeInfo = await fetchStoreInfo(storeId, merchantId); + setMerchantInfo(storeInfo); + setMerchantId(merchantId); + setStoreId(storeId); + setTransactionId(trimmed); + + const options = await getPaymentOptions(storeId); + const mapped = options.map((opt) => ({ + id: opt.type === "TOKEN" ? "token" : String(opt.voucherOwnershipId), + title: opt.name, + balance: opt.balance, + expiryDate: opt.expireDate, + icon: opt.type === "TOKEN" ? "🪙" : "🎟️", + disabled: !opt.usable, + })); + + setUsableVouchers(mapped); + + setTimeout(() => { + setPaymentStep("amount"); + setIsLoading(false); + }, 800); + } catch (err) { + console.error("거래번호 인식 실패:", err); + alert("유효하지 않은 거래번호입니다."); + setIsLoading(false); + } + }; + + const handlePayment = async (verifiedPassword: string) => { + if (isProcessing) return; + setIsProcessing(true); + + const idempotencyKey = crypto.randomUUID(); + + const amount = Number(paymentAmount); + const selectedVoucher = usableVouchers[carouselIndex]; + const isToken = selectedVoucher.id === "token"; + + try { + const response = isToken + ? await submitTokenPayment( + Number(merchantId), + amount, + verifiedPassword, + idempotencyKey + ) + : await submitVoucherPayment( + Number(selectedVoucher.id), + Number(merchantId), + Number(storeId), + amount, + verifiedPassword, + idempotencyKey + ); + + if (!response.isSuccess) { + console.error("결제에 실패했습니다.", response); + alert(response.message || "결제에 실패했습니다."); + return; + } + + setPaymentStep("result"); + } catch (e) { + console.error("결제 요청 오류:", e); + alert("결제 처리 중 오류가 발생했습니다."); + } finally { + setIsProcessing(false); + } + }; + + const handlePaymentComplete = () => { + router.push("/dashboard"); + }; + + const currentBalance = + usableVouchers.length > 0 ? usableVouchers[carouselIndex].balance : 0; + + return ( +
+ {(isProcessing || isLoading) && ( + + )} +
+
+ + {paymentStep === "scan" && ( + +
+ +
+ + +
+ )} + + {paymentStep === "manual" && ( + + + + )} + + {paymentStep === "amount" && merchantInfo && ( + +
+ {merchantInfo && ( + + )} +
+ +
+ setPaymentStep("password")} + > +
+ +
+
+
+
+ )} + + {paymentStep === "password" && ( + + { + handlePayment(password); + }} + /> + + )} + + {paymentStep === "result" && ( + + {!done ? ( + setDone(true)} + className="mb-6" + > + + + ) : ( + + + + )} + +

+ 결제 완료 +

+

+ 결제가 성공적으로 완료되었습니다. +

+ + {(() => { + const selected = usableVouchers[carouselIndex]; + const numericAmount = Number( + paymentAmount.replace(/,/g, "") || "0" + ); + + const adjustedVoucher = { + ...selected, + balance: selected.balance - numericAmount, + icon: selected.icon || "", + }; + + return ( + + ); + })()} + + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/reset-password/api/reset-password-auth.ts b/app/reset-password/api/reset-password-auth.ts new file mode 100644 index 0000000..1c5b41f --- /dev/null +++ b/app/reset-password/api/reset-password-auth.ts @@ -0,0 +1,35 @@ +import axios from "axios"; +import { getApiUrl } from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export const requestTempPassword = async (email: string) => { + try { + const response = await axios.post(`${API_URL}/api/users/findPw`, null, { + params: { email }, + }); + + const data = response.data; + + if (!data.isSuccess) { + const error = new Error(data.message); + (error as any).code = data.code; + throw error; + } + + return data; + } catch (err: any) { + // axios 자체 에러 처리도 포함 + const response = err?.response; + + if (response?.data && !response.data.isSuccess) { + const error = new Error(response.data.message); + (error as any).code = response.data.code; + throw error; + } + + // 네트워크 오류 등 + throw new Error("네트워크 오류 또는 알 수 없는 오류가 발생했습니다."); + } +}; + diff --git a/app/reset-password/components/ResetPasswordFrom.tsx b/app/reset-password/components/ResetPasswordFrom.tsx new file mode 100644 index 0000000..5831fda --- /dev/null +++ b/app/reset-password/components/ResetPasswordFrom.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { motion } from "framer-motion"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +interface Props { + email: string; + setEmail: (value: string) => void; + loading: boolean; + onSubmit: (email: string) => Promise; // 실패 시 메시지 리턴 +} + +export default function ResetPasswordForm({ email, setEmail, loading, onSubmit }: Props) { + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!email || !email.includes("@")) { + setError("올바른 이메일 주소를 입력해주세요."); + return; + } + + setError(null); + const serverError = await onSubmit(email); + if (serverError) { + setError(serverError); + } + }; + + return ( + +

임시 비밀번호 발급

+

+ 가입된 이메일 주소를 입력하시면
임시 비밀번호를 이메일로 보내드립니다. +

+ +
+
+ setEmail(e.target.value)} + disabled={loading} + /> + {error && ( +

+ + {error} +

+ )} +
+ + +
+
+ ); +} diff --git a/app/reset-password/components/ResetPasswordResult.tsx b/app/reset-password/components/ResetPasswordResult.tsx new file mode 100644 index 0000000..c5007a5 --- /dev/null +++ b/app/reset-password/components/ResetPasswordResult.tsx @@ -0,0 +1,38 @@ +import { motion } from "framer-motion"; +import { Mail } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface Props { + email: string; + onComplete: () => void; +} + +export default function ResetPasswordResult({ email, onComplete }: Props) { + return ( + +

비밀번호 초기화 완료

+

+ 임시 비밀번호가 {email}
발송되었습니다 +
+ 📮 메일함을 확인해 주세요! +

+ +
+

임시 비밀번호로 로그인 후 보안을 위해 비밀번호를 변경해주세요.

+
+ + +
+ ); +} diff --git a/app/reset-password/page.tsx b/app/reset-password/page.tsx new file mode 100644 index 0000000..0b923fb --- /dev/null +++ b/app/reset-password/page.tsx @@ -0,0 +1,114 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { CheckCircle, Loader2 } from "lucide-react" +import { toast } from "@/hooks/use-toast" +import { BackButton } from "@/components/back-button" +import { PageHeader } from "@/components/page-header" +import Image from "next/image"; +import ResetPasswordForm from "@/app/reset-password/components/ResetPasswordFrom"; +import ResetPasswordResult from "@/app/reset-password/components/ResetPasswordResult"; +import {requestTempPassword} from "@/app/reset-password/api/reset-password-auth"; + +export default function ResetPasswordPage() { + const router = useRouter() + const [step, setStep] = useState<"email" | "complete">("email") + const [email, setEmail] = useState("") + const [loading, setLoading] = useState(false) + + // 임시 비밀번호 발급 처리 + const handleResetPassword = async (email: string): Promise => { + setLoading(true); + try { + await requestTempPassword(email); + setStep("complete"); + return null; + } catch (err: any) { + return err.message || "오류가 발생했습니다."; + } finally { + setLoading(false); + } + }; + + // 완료 후 로그인 페이지로 이동 + const handleComplete = () => { + router.push("/login") + } + + // 페이지 진입 애니메이션 + const pageVariants = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + } + + return ( +
+ {/* 헤더 */} +
+ + +
+ + {/* 컨텐츠 */} +
+
+ {step === "email" && ( + +
+ Tokkit Mascot +
+ +
+ )} + + {step === "complete" && ( + + + + + + + + )} +
+
+
+ ) +} diff --git a/app/signup/complete/components/AccountNumberCard.tsx b/app/signup/complete/components/AccountNumberCard.tsx new file mode 100644 index 0000000..1c6a166 --- /dev/null +++ b/app/signup/complete/components/AccountNumberCard.tsx @@ -0,0 +1,34 @@ +import { motion } from "framer-motion"; +import { Store, Wallet } from "lucide-react"; + +interface Props { + accountNumber: string; + isMerchant: boolean; + businessName: string; +} + +export default function AccountNumberCard({ + accountNumber, + isMerchant, + businessName, + }: Props) { + return ( + +
+
+
+ {isMerchant ? : } +
+
+

전자지갑 계좌번호

+

{isMerchant ? `${businessName} 가맹점` : "Tokkit 전자지갑"}

+
+
+ +
+

{accountNumber}

+
+
+
+ ); +} diff --git a/app/signup/complete/components/CompleteFooterButton.tsx b/app/signup/complete/components/CompleteFooterButton.tsx new file mode 100644 index 0000000..6655fed --- /dev/null +++ b/app/signup/complete/components/CompleteFooterButton.tsx @@ -0,0 +1,26 @@ +import { Button } from "@/components/ui/button"; +import { ArrowRight } from "lucide-react"; +import { useRouter } from "next/navigation"; + +interface Props { + isMerchant: boolean; +} + +export default function CompleteFooterButton({ isMerchant }: Props) { + const router = useRouter(); + + return ( +
+ +
+ ); +} diff --git a/app/signup/complete/components/UsageGuideList.tsx b/app/signup/complete/components/UsageGuideList.tsx new file mode 100644 index 0000000..2b657c0 --- /dev/null +++ b/app/signup/complete/components/UsageGuideList.tsx @@ -0,0 +1,47 @@ +interface Props { + isMerchant: boolean; + businessName: string; +} + +export default function UsageGuideList({ isMerchant }: Props) { + return ( +
+
+

이용 안내

+
    + {isMerchant ? ( + <> +
  • + 1 +

    가맹점 대시보드에서 결제 내역을 확인할 수 있습니다.

    +
  • +
  • + 2 +

    정산 내역은 정산 페이지에서 확인 가능합니다.

    +
  • +
  • + 3 +

    QR코드를 통해 간편하게 결제를 받을 수 있습니다.

    +
  • + + ) : ( + <> +
  • + 1 +

    전자지갑을 통해 바우처를 관리하고 결제할 수 있습니다.

    +
  • +
  • + 2 +

    지갑 페이지에서 잔액 확인 및 충전이 가능합니다.

    +
  • +
  • + 3 +

    바우처 사용 내역은 거래 내역에서 확인할 수 있습니다.

    +
  • + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/signup/complete/components/WalletCompleteContent.tsx b/app/signup/complete/components/WalletCompleteContent.tsx new file mode 100644 index 0000000..9ee1122 --- /dev/null +++ b/app/signup/complete/components/WalletCompleteContent.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; +import AccountNumberCard from "./AccountNumberCard"; +import UsageGuideList from "./UsageGuideList"; +import CompleteFooterButton from "./CompleteFooterButton"; +import confetti from "canvas-confetti" // 테무폭죽 Import + +export default function WalletCompleteContent() { + const router = useRouter(); + const [accountNumber, setAccountNumber] = useState(""); + const [isMerchant, setIsMerchant] = useState(false); + const [businessName, setBusinessName] = useState(""); + + // 테무 폭죽 + useEffect(() => { + const duration = 3 * 1000 + const animationEnd = Date.now() + duration + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 } + + function randomInRange(min: number, max: number) { + return Math.random() * (max - min) + min + } + + const interval: NodeJS.Timeout = setInterval(() => { + const timeLeft = animationEnd - Date.now() + + if (timeLeft <= 0) { + return clearInterval(interval) + } + + const particleCount = 50 * (timeLeft / duration) + + // 왼쪽과 오른쪽에서 컨페티 발사 + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, + }) + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, + }) + }, 250) + + return () => clearInterval(interval) + }, []) + + useEffect(() => { + const storedAccountNumber = sessionStorage.getItem("accountNumber"); + if (storedAccountNumber) { + setAccountNumber(storedAccountNumber); + } + + const userType = sessionStorage.getItem("userType"); + if (userType === "merchant") { + setIsMerchant(true); + const businessInfoStr = sessionStorage.getItem("businessInfo"); + if (businessInfoStr) { + try { + const businessInfo = JSON.parse(businessInfoStr); + setBusinessName(businessInfo.businessName || ""); + } catch (e) { + console.error("Failed to parse business info", e); + } + } + } + }, []); + + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { when: "beforeChildren", staggerChildren: 0.1 }, + }, + }; + + const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { y: 0, opacity: 1 }, + }; + + return ( + + + Tokkit 로고 + + + + 마스코트 + + + +

전자지갑 개설 완료

+

+ {isMerchant + ? `${businessName} 가맹점의 전자지갑이 성공적으로 개설되었습니다.` + : "전자지갑이 성공적으로 개설되었습니다."} +

+
+ + + + + + +
+ ); +} diff --git a/app/signup/complete/page.tsx b/app/signup/complete/page.tsx new file mode 100644 index 0000000..031dbad --- /dev/null +++ b/app/signup/complete/page.tsx @@ -0,0 +1,5 @@ +import WalletCompleteContent from "./components/WalletCompleteContent"; + +export default function WalletCompletePage() { + return ; +} diff --git a/app/signup/wallet/components/wallet-intro.tsx b/app/signup/wallet/components/wallet-intro.tsx new file mode 100644 index 0000000..de7cb53 --- /dev/null +++ b/app/signup/wallet/components/wallet-intro.tsx @@ -0,0 +1,121 @@ +// components/WalletIntro.tsx +"use client" + +import { useRouter } from "next/navigation" +import Image from "next/image" +import { motion } from "framer-motion" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +export default function WalletIntro() { + const router = useRouter() + + return ( + + {/* Header */} +
+ + + 전자지갑 개설 + +
+ + {/* Content */} +
+ +
+ Tokkit 마스코트 +
+
+ + + 전자지갑 개설 및 회원가입 + + + + Tokkit 서비스 이용을 위한 전자지갑 개설과 회원가입을 진행합니다 + + + +
+
+

전자지갑 개설 절차

+
    + {steps.map((step, idx) => ( +
  1. +
    + {idx + 1} +
    +
    +

    {step.title}

    +

    {step.description}

    +
    +
  2. + ))} +
+
+ + +
+
+
+
+ ) +} + +const steps = [ + { + title: "약관 동의", + description: "서비스 이용 및 전자지갑 개설을 위한 약관 동의" + }, + { + title: "본인인증", + description: "이름, 주민등록번호, 주민등록증 발급일자를 통한 본인인증" + }, + { + title: "연락처 정보 입력", + description: "이메일, 전화번호 입력 및 인증" + }, + { + title: "간편 비밀번호 설정", + description: "전자지갑 이용을 위한 간편 비밀번호 설정" + } +] diff --git a/app/signup/wallet/contact/api/register-auth.ts b/app/signup/wallet/contact/api/register-auth.ts new file mode 100644 index 0000000..447d016 --- /dev/null +++ b/app/signup/wallet/contact/api/register-auth.ts @@ -0,0 +1,34 @@ +import axios from "axios"; + +import { getApiUrl } from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +// 이메일 인증 요청: 쿼리 파라미터 사용 +export const sendEmailVerificationCode = async (email: string) => { + const response = await axios.post(`${API_URL}/api/users/emailCheck`, null, { + params: { email }, + }); + return response.data; +}; + +// 이메일 인증번호 검증: JSON body 사용 +export const verifyEmailCode = async (email: string, code: string) => { + const response = await axios.post(`${API_URL}/api/users/verification`, { + email, + verification: code, + }); + return response.data; +}; + +// 회원가입 요청: JSON body로 보내기 +export const submitContactInfo = async (data: { + name: String; + email: string; + password: string; + phoneNumber: string; + simplePassword: string; +}) => { + const response = await axios.post(`${API_URL}/api/users/register`, data); + return response.data; +}; diff --git a/app/signup/wallet/contact/components/EmailVerificationBlock.tsx b/app/signup/wallet/contact/components/EmailVerificationBlock.tsx new file mode 100644 index 0000000..f003de3 --- /dev/null +++ b/app/signup/wallet/contact/components/EmailVerificationBlock.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Clock } from "lucide-react"; + +interface Props { + email: string; + setEmail: (val: string) => void; + verificationCode: string; + setVerificationCode: (val: string) => void; + isEmailSent: boolean; + isVerified: boolean; + remainingTime: number; + handleSendVerification: () => void; + handleVerifyCode: () => void; + isLoading: boolean; +} + +export default function EmailVerificationBlock({ + email, + setEmail, + verificationCode, + setVerificationCode, + isEmailSent, + isVerified, + remainingTime, + handleSendVerification, + handleVerifyCode, + isLoading, + }: Props) { + const verificationCodeRef = useRef(null); + + useEffect(() => { + if (isEmailSent && verificationCodeRef.current) { + verificationCodeRef.current.focus(); + } + }, [isEmailSent]); + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs < 10 ? "0" : ""}${secs}`; + }; + + const handleVerificationCodeChange = (e: React.ChangeEvent) => { + const value = e.target.value; // 영문자도 허용 + setVerificationCode(value.slice(0, 6)); + }; + + return ( +
+ +
+ setEmail(e.target.value)} + required + disabled={isVerified} + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + /> + +
+ + {isEmailSent && !isVerified && ( +
+
+ +
+ + {formatTime(remainingTime)} +
+
+
+ + +
+

이메일로 전송된 6자리 인증 코드를 입력하세요.

+
+ )} +
+ ); +} diff --git a/app/signup/wallet/contact/components/FormFeedbackMessage.tsx b/app/signup/wallet/contact/components/FormFeedbackMessage.tsx new file mode 100644 index 0000000..13e72cf --- /dev/null +++ b/app/signup/wallet/contact/components/FormFeedbackMessage.tsx @@ -0,0 +1,27 @@ +import { AlertCircle, CheckCircle2 } from "lucide-react"; +import { motion } from "framer-motion"; + +interface Props { + error?: string | null; + success?: string | null; +} + +export default function FormFeedbackMessage({ error, success }: Props) { + if (!error && !success) return null; + + return ( + + {error ? ( + + ) : ( + + )} +

{error || success}

+
+ ); +} \ No newline at end of file diff --git a/app/signup/wallet/contact/components/PasswordInputBlock.tsx b/app/signup/wallet/contact/components/PasswordInputBlock.tsx new file mode 100644 index 0000000..07f6a5d --- /dev/null +++ b/app/signup/wallet/contact/components/PasswordInputBlock.tsx @@ -0,0 +1,134 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Eye, EyeOff, Lock, Check, X } from "lucide-react"; +import React from "react"; + +interface PasswordValidation { + hasLength: boolean; + hasUpperCase: boolean; + hasLowerCase: boolean; + hasSpecialChar: boolean; + passwordsMatch: boolean; +} + +interface Props { + password: string; + confirmPassword: string; + setPassword: (val: string) => void; + setConfirmPassword: (val: string) => void; + showPassword: boolean; + setShowPassword: (val: boolean) => void; + showConfirmPassword: boolean; + setShowConfirmPassword: (val: boolean) => void; + passwordValidation: PasswordValidation; + passwordRef: React.RefObject; + confirmPasswordRef: React.RefObject; +} + +export default function PasswordInputBlock({ + password, + confirmPassword, + setPassword, + setConfirmPassword, + showPassword, + setShowPassword, + showConfirmPassword, + setShowConfirmPassword, + passwordValidation, + passwordRef, + confirmPasswordRef, + }: Props) { + return ( + <> +
+ +
+ setPassword(e.target.value)} + required + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0 pr-10" + ref={passwordRef} + /> + +
+ + {/* 비밀번호 유효성 표시 */} +
+
+ {passwordValidation.hasLength ? : } + 8자 이상 +
+
+ {passwordValidation.hasUpperCase ? : } + 대문자 포함 +
+
+ {passwordValidation.hasLowerCase ? : } + 소문자 포함 +
+
+ {passwordValidation.hasSpecialChar ? : } + 특수문자 포함 +
+
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + required + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0 pr-10" + ref={confirmPasswordRef} + /> + +
+ + {/* 비밀번호 일치 여부 표시 */} + {confirmPassword && ( +
+ {passwordValidation.passwordsMatch ? ( + <> + 비밀번호가 일치합니다. + + ) : ( + <> + 비밀번호가 일치하지 않습니다. + + )} +
+ )} +
+ + ); +} diff --git a/app/signup/wallet/contact/components/PhoneInputBlock.tsx b/app/signup/wallet/contact/components/PhoneInputBlock.tsx new file mode 100644 index 0000000..9a5109e --- /dev/null +++ b/app/signup/wallet/contact/components/PhoneInputBlock.tsx @@ -0,0 +1,28 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import React from "react"; + +interface Props { + phoneNumber: string; + onChange: (e: React.ChangeEvent) => void; + phoneRef: React.RefObject; +} + +export default function PhoneInputBlock({ phoneNumber, onChange, phoneRef }: Props) { + return ( +
+ + +
+ ); +} diff --git a/app/signup/wallet/contact/components/SubmitButton.tsx b/app/signup/wallet/contact/components/SubmitButton.tsx new file mode 100644 index 0000000..f35ea5e --- /dev/null +++ b/app/signup/wallet/contact/components/SubmitButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "@/components/ui/button"; + +interface Props { + isLoading: boolean; + isDisabled: boolean; + onClick: () => void; + label?: string; +} + +export default function SubmitButton({ isLoading, isDisabled, onClick, label = "다음" }: Props) { + return ( + + ); +} \ No newline at end of file diff --git a/app/signup/wallet/contact/page.tsx b/app/signup/wallet/contact/page.tsx new file mode 100644 index 0000000..b2a4dd1 --- /dev/null +++ b/app/signup/wallet/contact/page.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { motion } from "framer-motion"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Mail } from "lucide-react"; +import Header from "@/components/common/Header"; + +import EmailVerificationBlock from "@/app/signup/wallet/contact/components/EmailVerificationBlock"; +import PasswordInputBlock from "@/app/signup/wallet/contact/components/PasswordInputBlock"; +import PhoneInputBlock from "@/app/signup/wallet/contact/components/PhoneInputBlock"; +import FormFeedbackMessage from "@/app/signup/wallet/contact/components/FormFeedbackMessage"; +import SubmitButton from "@/app/signup/wallet/contact/components/SubmitButton"; +import { sendEmailVerificationCode, verifyEmailCode, submitContactInfo } from "@/app/signup/wallet/contact/api/register-auth"; + +export default function WalletContactPage() { + const router = useRouter(); + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [verificationCode, setVerificationCode] = useState(""); + const [isEmailSent, setIsEmailSent] = useState(false); + const [isVerified, setIsVerified] = useState(false); + const [remainingTime, setRemainingTime] = useState(300); + const [verificationAttempts, setVerificationAttempts] = useState(0); + + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const [phoneNumber, setPhone] = useState(""); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const passwordRef = useRef(null); + const confirmPasswordRef = useRef(null); + const phoneRef = useRef(null); + + const passwordValidation = { + hasLength: password.length >= 8, + hasUpperCase: /[A-Z]/.test(password), + hasLowerCase: /[a-z]/.test(password), + hasSpecialChar: /[!@#$%^&*(),.?":{}|<>]/.test(password), + passwordsMatch: password === confirmPassword && password !== "", + }; + + const isPasswordValid = + passwordValidation.hasLength && + passwordValidation.hasUpperCase && + passwordValidation.hasLowerCase && + passwordValidation.hasSpecialChar; + + useEffect(() => { + const storedName = sessionStorage.getItem("verifiedName"); + if (storedName) { + setName(storedName); + } + + let timer: NodeJS.Timeout; + if (isEmailSent && remainingTime > 0) { + timer = setInterval(() => { + setRemainingTime((prev) => prev - 1); + }, 1000); + } else if (remainingTime === 0 && isEmailSent) { + setIsEmailSent(false); + setError("인증 시간이 만료되었습니다. 다시 인증해주세요."); + } + + return () => clearInterval(timer); + }, [isEmailSent, remainingTime]); + + const handleSendVerification = async () => { + setIsLoading(true); + setError(null); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + setError("유효한 이메일 주소를 입력해주세요."); + setIsLoading(false); + return; + } + try { + await sendEmailVerificationCode(email); + setIsEmailSent(true); + setSuccess("인증 코드가 이메일로 전송되었습니다."); + setRemainingTime(300); + setVerificationAttempts((prev) => prev + 1); + if (verificationAttempts >= 4) { + setError("최대 인증 시도 횟수를 초과했습니다. 24시간 후에 다시 시도해주세요."); + setIsEmailSent(false); + } + } catch { + setError("인증 코드 전송에 실패했습니다. 다시 시도해주세요."); + } finally { + setIsLoading(false); + } + }; + + const handleVerifyCode = async () => { + setIsLoading(true); + setError(null); + try { + await verifyEmailCode(email, verificationCode); + setIsVerified(true); + setIsEmailSent(false); + setSuccess("이메일 인증이 완료되었습니다."); + } catch { + setError("인증에 실패했습니다. 다시 시도해주세요."); + } finally { + setIsLoading(false); + } + }; + + const handlePhoneChange = (e: React.ChangeEvent) => { + const raw = e.target.value.replace(/[^0-9]/g, ""); + const formatted = + raw.length <= 3 + ? raw + : raw.length <= 7 + ? `${raw.slice(0, 3)}-${raw.slice(3)}` + : `${raw.slice(0, 3)}-${raw.slice(3, 7)}-${raw.slice(7, 11)}`; + setPhone(formatted); + }; + + const handleNext = () => { + setError(null); + setSuccess(null); + + if (!isVerified) return setError("이메일 인증을 완료해주세요."); + if (!isPasswordValid) return setError("비밀번호는 8자 이상, 대문자, 소문자, 특수문자를 포함해야 합니다."); + if (!passwordValidation.passwordsMatch) return setError("비밀번호가 일치하지 않습니다."); + if (!/^[0-9]{10,11}$/.test(phoneNumber.replace(/-/g, ""))) return setError("유효한 전화번호를 입력해주세요."); + + const payload = { email, password, phoneNumber, name, }; + sessionStorage.setItem("signupPayload", JSON.stringify(payload)); + + router.push("/signup/wallet/password"); + }; + + + return ( + +
router.back()} /> + + {/* Main content */} +
+
+ +
+ +
+

연락처 정보

+

전자지갑 개설을 위한 연락처 정보를 입력해주세요.

+
+ + + + + {isVerified && ( + <> + } + confirmPasswordRef={confirmPasswordRef as React.RefObject} + /> + } /> + + )} + + + + + + + +
+
+ + ); +} \ No newline at end of file diff --git a/app/signup/wallet/page.tsx b/app/signup/wallet/page.tsx new file mode 100644 index 0000000..31dba9a --- /dev/null +++ b/app/signup/wallet/page.tsx @@ -0,0 +1,5 @@ +import WalletIntro from "@/app/signup/wallet/components/wallet-intro" + +export default function WalletIntroPage() { + return +} diff --git a/app/signup/wallet/password/components/SimplePasswordStep.tsx b/app/signup/wallet/password/components/SimplePasswordStep.tsx new file mode 100644 index 0000000..ce668b0 --- /dev/null +++ b/app/signup/wallet/password/components/SimplePasswordStep.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import VirtualKeypad from "@/components/virtual-keypad"; + +interface Props { + onComplete: (password: string) => void; + isLoading?: boolean; +} + +export default function SimplePasswordStep({ onComplete, isLoading = false }: Props) { + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [step, setStep] = useState<"first" | "confirm">("first"); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const handleKeypadComplete = (pinCode: string) => { + if (step === "first") { + setPassword(pinCode); + setStep("confirm"); + } else { + setConfirmPassword(pinCode); + + if (pinCode !== password) { + setError("비밀번호가 일치하지 않습니다."); + setTimeout(() => { + setConfirmPassword(""); + setError(null); + setStep("first"); + }, 1500); + return; + } + + setSuccess("비밀번호가 설정되었습니다."); + onComplete(pinCode); + } + }; + + const resetPassword = () => { + setPassword(""); + setConfirmPassword(""); + setStep("first"); + setError(null); + setSuccess(null); + }; + + return ( +
+ + + {step == "first" && ( + +

간편 비밀번호 입력

+
+ )} + {step === "confirm" && ( + +

간편 비밀번호 재입력

+
+ )} + + +
+
+ + {/* 메시지 */} + + {error && ( + + +

{error}

+
+ )} +
+ + + {success && ( + + +

{success}

+
+ )} +
+ + {step === "confirm" && !isLoading && ( + + + + )} +
+ ); +} diff --git a/app/signup/wallet/password/page.tsx b/app/signup/wallet/password/page.tsx new file mode 100644 index 0000000..c37b92f --- /dev/null +++ b/app/signup/wallet/password/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import SimplePasswordStep from "@/app/signup/wallet/password/components/SimplePasswordStep"; +import { submitContactInfo } from "@/app/signup/wallet/contact/api/register-auth"; +import Link from "next/link"; +import Image from "next/image"; + +export default function PasswordSetupPage() { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (simplePassword: string) => { + try { + const payload = JSON.parse(sessionStorage.getItem("signupPayload") || "{}") as { + email: string; + password: string; + phoneNumber: string; + name: string; + }; + + if (!payload.email || !payload.password || !payload.phoneNumber || !payload.name) { + alert("이전 입력 정보가 유실되었습니다."); + return router.push("/signup/wallet/contact"); + } + + setIsLoading(true); + + const response = await submitContactInfo({ + email: payload.email, + password: payload.password, + phoneNumber: payload.phoneNumber, + name: payload.name, + simplePassword, + }); + + const accountNumber = response.result.accountNumber; + sessionStorage.setItem("accountNumber", accountNumber); + + sessionStorage.removeItem("signupPayload"); + sessionStorage.removeItem("verifiedName"); + + router.push("/signup/complete"); + } catch (err) { + alert("회원가입에 실패했습니다. 다시 시도해주세요."); + } finally { + setIsLoading(false); + } + }; + + return ( + +
+ + + 간편 비밀번호 설정 + +
+ + +
+
+
+ + Tokkit Logo + +
+ + +
+
+
+
+ ); +} diff --git a/app/signup/wallet/terms/[id]/page.tsx b/app/signup/wallet/terms/[id]/page.tsx new file mode 100644 index 0000000..e474657 --- /dev/null +++ b/app/signup/wallet/terms/[id]/page.tsx @@ -0,0 +1,150 @@ +// /signup/wallet/terms/page.tsx +"use client" + +import { useState, useEffect, useRef } from "react" +import { useRouter, useParams, useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { ArrowLeft, Check } from "lucide-react" +import { motion } from "framer-motion" +import { walletTerms as termsData } from "@/app/signup/wallet/terms/data/walletTerms" + +interface TermContent { + id: string + title: string + content: string +} + +export default function TermDetailPage() { + const router = useRouter() + const params = useParams() + const searchParams = useSearchParams() + const termId = params.id as string + const [term, setTerm] = useState(null) + const [isScrolledToBottom, setIsScrolledToBottom] = useState(false) + const contentRef = useRef(null) + const scrollCheckTimeoutRef = useRef(null) + + const getAgreedTerms = () => { + const agreedParam = searchParams.get("agreed") + return agreedParam ? agreedParam.split(",") : [] + } + + useEffect(() => { + const foundTerm = termsData.find((t) => t.id === termId) + if (foundTerm) { + setTerm(foundTerm) + } else { + router.push("/signup/wallet/terms") + } + }, [termId, router]) + + useEffect(() => { + const currentRef = contentRef.current + + const checkInitialScroll = () => { + if (currentRef) { + const { scrollHeight, clientHeight } = currentRef + if (scrollHeight <= clientHeight) { + setIsScrolledToBottom(true) + } + } + } + + const handleScrollEvent = () => { + if (currentRef) { + const { scrollTop, scrollHeight, clientHeight } = currentRef + if (scrollTop + clientHeight >= scrollHeight * 0.8) { + setIsScrolledToBottom(true) + } + } + } + + if (currentRef) { + if (scrollCheckTimeoutRef.current) { + clearTimeout(scrollCheckTimeoutRef.current) + } + scrollCheckTimeoutRef.current = setTimeout(() => { + checkInitialScroll() + }, 300) + + currentRef.addEventListener("scroll", handleScrollEvent) + } + + return () => { + if (currentRef) { + currentRef.removeEventListener("scroll", handleScrollEvent) + } + if (scrollCheckTimeoutRef.current) { + clearTimeout(scrollCheckTimeoutRef.current) + } + } + }, [term]) + + const handleAgree = () => { + const agreedTerms = getAgreedTerms() + const updatedTerms = [...agreedTerms] + if (!updatedTerms.includes(termId)) { + updatedTerms.push(termId) + } + router.push(`/signup/wallet/terms?agreed=${updatedTerms.join(",")}`) + } + + if (!term) { + return ( +
+
+
+ ) + } + + return ( + +
+ +

{term.title}

+
+ +
+
+ {term.content} +
+ + + + +
+
+ ) +} diff --git a/app/signup/wallet/terms/components/TermModal.tsx b/app/signup/wallet/terms/components/TermModal.tsx new file mode 100644 index 0000000..447388e --- /dev/null +++ b/app/signup/wallet/terms/components/TermModal.tsx @@ -0,0 +1,73 @@ +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import { Term } from "@/app/signup/wallet/terms/data/walletTerms"; +import {FC} from "react"; // 타입 재사용 + +interface TermModalProps { + term: Term; + onClose: () => void; + onAgree: () => void; +} + +const modalVariants = { + hidden: { opacity: 0, y: 50, scale: 0.95 }, + visible: { opacity: 1, y: 0, scale: 1 }, + exit: { opacity: 0, y: 50, scale: 0.95 }, +}; + +const TermModal: FC = ({ term, onClose, onAgree }) => { + if (!term) return null; + + return ( + + +
+

+ {term.title} +

+ +
+ +
+ {term.content} +
+ +
+ +
+
+
+ ); +}; + +export default TermModal; \ No newline at end of file diff --git a/app/signup/wallet/terms/components/TermsAgreementPage.tsx b/app/signup/wallet/terms/components/TermsAgreementPage.tsx new file mode 100644 index 0000000..b805c16 --- /dev/null +++ b/app/signup/wallet/terms/components/TermsAgreementPage.tsx @@ -0,0 +1,182 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { motion, AnimatePresence } from "framer-motion" +import { Button } from "@/components/ui/button" +import { ArrowLeft, AlertCircle } from "lucide-react" +import TermsModal from "@/app/signup/wallet/terms/components/TermModal" +import TermsCardList from "@/app/signup/wallet/terms/components/TermsCardList" +import { Term } from "@/app/signup/wallet/terms/data/walletTerms" + +interface Props { + terms: Term[] + redirectTo: string + title?: string + description?: string +} + +export default function TermsAgreementPage({ terms, redirectTo, title = "약관 동의", description = "안전한 서비스 이용을 위해 아래 약관에 동의해주세요." }: Props) { + const router = useRouter() + const searchParams = useSearchParams() + + const [agreedTerms, setAgreedTerms] = useState([]) + const [allAgreed, setAllAgreed] = useState(false) + const [showAlert, setShowAlert] = useState(false) + const [currentTermIndex, setCurrentTermIndex] = useState(null) + const [viewedTerms, setViewedTerms] = useState([]) + const [isAllTermsFlow, setIsAllTermsFlow] = useState(false) + const initialRenderRef = useRef(true) + + useEffect(() => { + const agreedParam = searchParams.get("agreed") + if (agreedParam) setAgreedTerms(agreedParam.split(",")) + + const viewedParam = searchParams.get("viewed") + if (viewedParam) setViewedTerms(viewedParam.split(",")) + }, [searchParams]) + + useEffect(() => { + if (initialRenderRef.current) { + initialRenderRef.current = false + return + } + setAllAgreed(terms.every(term => agreedTerms.includes(term.id))) + }, [agreedTerms, terms]) + + const handleToggleTerm = (termId: string) => { + if (!viewedTerms.includes(termId)) { + const idx = terms.findIndex(term => term.id === termId) + if (idx !== -1) { + setCurrentTermIndex(idx) + setIsAllTermsFlow(false) + } + return + } + setAgreedTerms(prev => prev.includes(termId) ? prev.filter(id => id !== termId) : [...prev, termId]) + } + + const handleToggleAll = () => { + if (allAgreed) { + setAgreedTerms([]) + } else { + const allViewed = terms.every(term => viewedTerms.includes(term.id)) + if (allViewed) { + setAgreedTerms(terms.map(term => term.id)) + } else { + setIsAllTermsFlow(true) + const firstNotViewed = terms.findIndex(term => !viewedTerms.includes(term.id)) + setCurrentTermIndex(firstNotViewed !== -1 ? firstNotViewed : 0) + } + } + } + + const handleViewTerm = (termId: string) => { + const idx = terms.findIndex(term => term.id === termId) + if (idx !== -1) { + setCurrentTermIndex(idx) + setIsAllTermsFlow(false) + } + } + + const handleSubmit = () => { + const required = terms.filter(term => term.required).map(term => term.id) + const valid = required.every(termId => agreedTerms.includes(termId)) + if (valid) { + router.push(redirectTo) + } else { + setShowAlert(true) + setTimeout(() => setShowAlert(false), 3000) + } + } + + const handleAgreeCurrentTerm = () => { + if (currentTermIndex === null) return + const current = terms[currentTermIndex] + if (!viewedTerms.includes(current.id)) setViewedTerms(prev => [...prev, current.id]) + if (!agreedTerms.includes(current.id)) setAgreedTerms(prev => [...prev, current.id]) + + if (isAllTermsFlow && currentTermIndex < terms.length - 1) { + setCurrentTermIndex(currentTermIndex + 1) + } else { + setCurrentTermIndex(null) + setIsAllTermsFlow(false) + if (isAllTermsFlow) setAgreedTerms(terms.map(term => term.id)) + } + } + + const handleCloseTermModal = () => { + if (currentTermIndex !== null) { + const current = terms[currentTermIndex] + if (!viewedTerms.includes(current.id)) setViewedTerms(prev => [...prev, current.id]) + } + setCurrentTermIndex(null) + setIsAllTermsFlow(false) + } + + return ( + + + {/* header */} +
+ + + {title} + +
+ + {/* content */} +
+
+ +

{title}

+

{description}

+
+ + + {showAlert && ( + + +

모든 필수 약관에 동의해야 진행할 수 있습니다.

+
+ )} +
+ + + + + + +
+
+ + {currentTermIndex !== null && ( + + )} +
+ ) +} diff --git a/app/signup/wallet/terms/components/TermsCardList.tsx b/app/signup/wallet/terms/components/TermsCardList.tsx new file mode 100644 index 0000000..69b81af --- /dev/null +++ b/app/signup/wallet/terms/components/TermsCardList.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { Check, Eye } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Term } from "@/app/signup/wallet/terms/data/walletTerms"; +import { FC } from "react"; + +interface TermsCardListProps { + terms: Term[]; + agreedTerms: string[]; + viewedTerms: string[]; + allAgreed: boolean; + onToggleAll: () => void; + onToggleTerm: (termId: string) => void; + onViewTerm: (termId: string) => void; +} + +const TermsCardList: FC = ({ + terms, + agreedTerms, + viewedTerms, + allAgreed, + onToggleAll, + onToggleTerm, + onViewTerm, + }) => { + return ( + + {/* 모두 동의하기 */} +
+ + {allAgreed && } + + + 모든 약관에 동의합니다 + +
+ + {/* 개별 약관 */} + {terms.map((term, index) => ( + +
+ onToggleTerm(term.id)} + variants={{ + checked: { scale: 1 }, + unchecked: { scale: 0.9 } + }} + animate={agreedTerms.includes(term.id) ? "checked" : "unchecked"} + > + {agreedTerms.includes(term.id) && ( + + )} + +
+ + {term.required ? ( + (필수) + ) : ( + (선택) + )} +
+
+ +
+ ))} +
+ ); +}; + +export default TermsCardList; \ No newline at end of file diff --git a/app/signup/wallet/terms/data/walletTerms.ts b/app/signup/wallet/terms/data/walletTerms.ts new file mode 100644 index 0000000..dc81ddd --- /dev/null +++ b/app/signup/wallet/terms/data/walletTerms.ts @@ -0,0 +1,197 @@ +// data/terms/walletTerms.ts +export interface Term { + id: string + title: string + required: boolean + content: string +} + +export const walletTerms: Term[] = [ + { + id: "wallet-service", + title: "전자지갑 서비스 이용약관", + required: true, + content: `제1조 (목적) +이 약관은 Tokkit(이하 "회사"라 합니다)이 제공하는 전자지갑 서비스(이하 "서비스"라 합니다)의 이용과 관련하여 회사와 이용자 간의 권리, 의무 및 책임사항, 기타 필요한 사항을 규정함을 목적으로 합니다. + +제2조 (용어의 정의) +① "전자지갑"이란 이용자가 중앙은행 디지털 화폐(CBDC)를 안전하게 보관하고 이용할 수 있도록 회사가 제공하는 전자적 수단을 말합니다. +② "이용자"란 이 약관에 따라 회사가 제공하는 서비스를 이용하는 자를 말합니다. +③ "CBDC"란 중앙은행이 발행하는 디지털 형태의 법정 화폐를 말합니다. + +제3조 (약관의 효력 및 변경) +① 이 약관은 서비스를 이용하고자 하는 모든 이용자에게 적용됩니다. +② 회사는 관련 법령을 위배하지 않는 범위에서 이 약관을 개정할 수 있습니다. +③ 회사가 약관을 변경할 경우에는 적용일자 및 변경사유를 명시하여 서비스 내에 공지합니다. + +제4조 (서비스의 내용) +① 회사가 제공하는 서비스는 다음 각 호와 같습니다. + 1. CBDC 보관 및 관리 서비스 + 2. CBDC 송금 및 결제 서비스 + 3. 기타 회사가 정하는 서비스 +② 회사는 서비스의 내용을 변경하거나 추가할 수 있으며, 이 경우 회사는 변경 또는 추가 내용을 서비스 내에 공지합니다. + +제5조 (서비스 이용 제한) +① 회사는 다음 각 호에 해당하는 경우 서비스 이용을 제한할 수 있습니다. + 1. 이용자가 이 약관을 위반한 경우 + 2. 이용자가 법령을 위반한 경우 + 3. 이용자가 타인의 명의나 정보를 도용한 경우 + 4. 기타 회사가 정한 사유에 해당하는 경우 +② 서비스 이용이 제한된 경우, 회사는 이용자에게 그 사유 및 기간을 통지합니다. + +제6조 (이용자의 의무) +① 이용자는 서비스 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다. + 1. 타인의 정보를 도용하거나 허위 정보를 제공하는 행위 + 2. 회사의 서비스를 이용하여 불법적인 목적을 달성하고자 하는 행위 + 3. 회사의 서비스를 이용하여 타인에게 피해를 주는 행위 + 4. 기타 관련 법령에 위배되는 행위 + +제7조 (회사의 의무) +① 회사는 이용자가 안전하게 서비스를 이용할 수 있도록 개인정보 보호를 위한 보안 시스템을 갖추어야 합니다. +② 회사는 이용자로부터 제기되는 의견이나 불만이 정당하다고 인정할 경우에는 신속히 처리하여야 합니다. + +제8조 (손해배상) +① 회사는 서비스 제공과 관련하여 회사의 고의 또는 과실로 인해 이용자에게 손해가 발생한 경우, 관련 법령에 따라 손해를 배상합니다. +② 이용자가 이 약관을 위반하여 회사에 손해를 입힌 경우, 이용자는 회사에 대하여 그 손해를 배상하여야 합니다. + +제9조 (분쟁해결) +① 서비스 이용과 관련하여 회사와 이용자 간에 분쟁이 발생한 경우, 양 당사자는 분쟁의 해결을 위해 성실히 협의합니다. +② 제1항의 협의에서 분쟁이 해결되지 않을 경우, 관련 법령에 따라 처리합니다. + +제10조 (약관의 해석) +이 약관에 명시되지 않은 사항은 관련 법령 및 상관례에 따릅니다.`, + }, + { + id: "personal-info", + title: "개인정보 수집 및 이용 동의", + required: true, + content: `개인정보 수집 및 이용 동의 + +Tokkit(이하 "회사"라 합니다)은 전자지갑 서비스 제공을 위해 다음과 같이 개인정보를 수집 및 이용합니다. 내용을 자세히 읽으신 후 동의 여부를 결정하여 주시기 바랍니다. + +1. 수집하는 개인정보 항목 +- 필수항목: 성명, 생년월일, 성별, 휴대전화번호, 이메일 주소, 계좌정보, 주민등록번호(실명확인용) +- 선택항목: 주소, 직업, 소득수준 + +2. 개인정보 수집 및 이용 목적 +- 서비스 제공 및 계약 이행: 전자지갑 서비스 제공, 본인 확인, 거래 내역 관리 +- 서비스 개선: 신규 서비스 개발, 기존 서비스 개선 +- 고객 관리: 고객 문의 응대, 공지사항 전달 +- 법령 준수: 관련 법령에 따른 의무 이행 + +3. 개인정보의 보유 및 이용 기간 +- 회원 탈퇴 시까지 또는 법정 의무 보유기간까지 +- 전자금융거래법에 따라 전자금융거래에 관한 기록은 5년간 보관 +- 통신비밀보호법에 따라 접속 로그는 3개월간 보관 + +4. 동의 거부권 및 거부 시 불이익 +- 필수항목에 대한 동의를 거부할 경우 서비스 이용이 제한됩니다. +- 선택항목에 대한 동의를 거부하더라도 서비스 이용에 제한은 없으나, 일부 서비스 이용이 제한될 수 있습니다. + +5. 개인정보의 제3자 제공 +- 회사는 이용자의 개인정보를 원칙적으로 외부에 제공하지 않습니다. +- 다만, 아래의 경우에는 예외로 합니다. + 1) 이용자가 사전에 동의한 경우 + 2) 법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 요청한 경우 + +6. 개인정보의 안전성 확보 조치 +- 회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취하고 있습니다. + 1) 관리적 조치: 내부관리계획 수립 및 시행, 정기적 직원 교육 + 2) 기술적 조치: 개인정보처리시스템 접근 제한, 암호화 기술 적용, 접속 기록 보관 + 3) 물리적 조치: 전산실, 자료보관실 등의 접근 통제 + +7. 이용자의 권리와 행사 방법 +- 이용자는 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다. +- 권리 행사는 회사에 대해 서면, 전화, 전자우편, 모사전송(FAX) 등을 통하여 하실 수 있으며 회사는 이에 대해 지체 없이 조치하겠습니다. + +8. 개인정보 보호책임자 및 연락처 +- 개인정보 보호책임자: 홍길동 +- 연락처: teamtokkit@gmail.com`, + }, + { + id: "financial-info", + title: "금융정보 제공 동의", + required: true, + content: `금융정보 제공 동의서 + +Tokkit(이하 "회사"라 합니다)은 전자지갑 서비스 제공을 위해 다음과 같이 금융정보를 수집 및 이용합니다. 내용을 자세히 읽으신 후 동의 여부를 결정하여 주시기 바랍니다. + +1. 금융정보 제공 동의 대상 기관 +- 금융결제원, 은행연합회, 신용정보집중기관, 신용정보회사, 금융회사(은행, 카드사 등) + +2. 금융정보 수집 항목 +- 계좌정보: 계좌번호, 은행명, 예금주명 +- 거래정보: 거래내역, 거래금액, 거래일시 +- 신용정보: 신용등급, 연체정보, 대출정보 + +3. 금융정보 수집 및 이용 목적 +- 전자지갑 서비스 제공 및 운영 +- 금융사고 방지 및 조사 +- 법령상 의무 이행 +- 금융거래 내역 관리 및 분석 + +4. 금융정보의 보유 및 이용 기간 +- 회원 탈퇴 시까지 또는 법정 의무 보유기간까지 +- 전자금융거래법에 따라 전자금융거래에 관한 기록은 5년간 보관 +- 금융실명거래 및 비밀보장에 관한 법률에 따른 거래기록은 5년간 보관 + +5. 금융정보 제공 동의의 효력 기간 +- 서비스 이용계약 체결일로부터 서비스 해지일 또는 회원 탈퇴일까지 +- 단, 관련 법령에 따라 보존할 필요가 있는 경우 해당 법령에서 정한 기간까지 + +6. 동의 거부권 및 거부 시 불이익 +- 본 금융정보 제공 동의는 전자지갑 서비스 이용을 위한 필수적 사항으로, 동의를 거부할 경우 서비스 이용이 제한됩니다. + +7. 금융정보의 파기 절차 및 방법 +- 회사는 금융정보 수집 및 이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. +- 전자적 파일 형태로 저장된 금융정보는 복구 불가능한 방법으로 영구 삭제하며, 종이에 출력된 금융정보는 분쇄기로 분쇄하거나 소각하여 파기합니다. + +8. 금융정보의 안전성 확보 조치 +- 회사는 금융정보의 안전성 확보를 위해 다음과 같은 조치를 취하고 있습니다. + 1) 금융정보 접근 권한 제한 및 접근 통제 시스템 구축 + 2) 금융정보 송수신 시 암호화 기술 적용 + 3) 금융정보 처리 시스템에 대한 접속 기록 보관 및 위변조 방지 조치 + 4) 금융정보 보호를 위한 내부관리계획 수립 및 시행 + +9. 금융정보 보호책임자 및 연락처 +- 금융정보 보호책임자: 김철수 +- 연락처: teamtokkit@gmail.com`, + }, + { + id: "marketing", + title: "마케팅 정보 수신 동의", + required: false, + content: `마케팅 정보 수신 동의 + +Tokkit(이하 "회사"라 합니다)은 이용자에게 유용한 서비스 및 이벤트 정보를 제공하기 위해 다음과 같이 마케팅 정보를 수신하는 것에 대한 동의를 받고 있습니다. 내용을 자세히 읽으신 후 동의 여부를 결정하여 주시기 바랍니다. + +1. 마케팅 정보 수신 동의 내용 +- 회사가 제공하는 서비스, 이벤트, 프로모션 등의 광고성 정보 + +2. 마케팅 정보 수신 방법 +- 이메일, SMS, 푸시 알림, 우편 등 + +3. 마케팅 정보 수신 목적 +- 신규 서비스 및 이벤트 안내 +- 맞춤형 혜택 및 프로모션 제공 +- 서비스 개선을 위한 의견 수렴 + +4. 마케팅 정보 수신 동의의 효력 기간 +- 회원 탈퇴 시 또는 마케팅 정보 수신 동의 철회 시까지 + +5. 동의 거부권 및 거부 시 불이익 +- 본 마케팅 정보 수신 동의는 선택사항으로, 동의를 거부하더라도 기본적인 서비스 이용에는 제한이 없습니다. +- 다만, 동의하지 않을 경우 회사가 제공하는 각종 혜택 및 이벤트 정보를 받아보실 수 없습니다. + +6. 마케팅 정보 수신 동의 철회 방법 +- 회원은 언제든지 마케팅 정보 수신 동의를 철회할 수 있습니다. +- 철회 방법: 서비스 내 '설정 > 알림 설정'에서 변경 또는 고객센터(1588-1234)로 요청 + +7. 개인정보의 제3자 제공 및 위탁 +- 회사는 마케팅 활동을 위해 필요한 경우 이용자의 개인정보를 외부 전문업체에 위탁할 수 있습니다. +- 위탁 업체 및 위탁 업무 내용은 회사 홈페이지의 '개인정보처리방침'에서 확인하실 수 있습니다. + +8. 마케팅 정보 관련 문의 +- 마케팅 정보 수신과 관련하여 문의사항이 있으신 경우, 이메일(teamtokkit@gmail.com)로 문의해 주시기 바랍니다.` + } +] diff --git a/app/signup/wallet/terms/page.tsx b/app/signup/wallet/terms/page.tsx new file mode 100644 index 0000000..d40f34c --- /dev/null +++ b/app/signup/wallet/terms/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react" +import { walletTerms } from "@/app/signup/wallet/terms/data/walletTerms" +import TermsAgreementPage from "@/app/signup/wallet/terms/components/TermsAgreementPage" + +function WalletTermsContent() { + return ( + + ) +} + +export default function WalletTermsPage() { + return ( + Loading...
}> + + + ) +} diff --git a/app/signup/wallet/verify/api/ocr.ts b/app/signup/wallet/verify/api/ocr.ts new file mode 100644 index 0000000..fab52d3 --- /dev/null +++ b/app/signup/wallet/verify/api/ocr.ts @@ -0,0 +1,30 @@ +import { getApiUrl } from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export interface OcrResponse { + name: string; + residentId: string; + issueDate: string; +} + +export async function extractIdCardInfo(imageFile: File): Promise { + const formData = new FormData(); + formData.append("image", imageFile); + + const res = await fetch(`${API_URL}/api/ocr/idCard`, { + method: "POST", + body: formData, + }); + + if (!res.ok) { + throw new Error("OCR 요청 실패"); + } + + const json = await res.json(); + return { + name: json.result.name, + residentId: json.result.rrnFront + json.result.rrnBack, + issueDate: json.result.issuedDate, + }; +} diff --git a/app/signup/wallet/verify/components/CaptureStep.tsx b/app/signup/wallet/verify/components/CaptureStep.tsx new file mode 100644 index 0000000..fff98aa --- /dev/null +++ b/app/signup/wallet/verify/components/CaptureStep.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ShieldCheck, Camera, Upload, X, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { extractIdCardInfo } from "@/app/signup/wallet/verify/api/ocr"; +import Image from "next/image"; + +interface CaptureStepProps { + onNext: () => void; + onRetry: () => void; + onBack: () => void; + setCapturedData: (data: { + name: string; + residentIdFront: string; + residentIdBack: string; + issueDate: string; + }) => void; + selectedMethod: "camera" | "upload" | null; + setSelectedMethod: (method: "camera" | "upload") => void; +} + +export default function CaptureStep({ + onNext, + onRetry, + onBack, + setCapturedData, + selectedMethod, + setSelectedMethod, + }: CaptureStepProps) { + const fileInputRef = useRef(null); + const videoRef = useRef(null); + const canvasRef = useRef(null); + const [showCamera, setShowCamera] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [capturedImage, setCapturedImage] = useState(null); + + useEffect(() => { + if (showCamera) { + navigator.mediaDevices + .getUserMedia({ video: { facingMode: "environment" } }) + .then((stream) => { + if (videoRef.current) { + videoRef.current.srcObject = stream; + } + }) + .catch(() => alert("카메라 접근 실패")); + } + + return () => { + if (videoRef.current?.srcObject) { + const tracks = (videoRef.current.srcObject as MediaStream).getTracks(); + tracks.forEach((track) => track.stop()); + } + }; + }, [showCamera]); + + const handleCapture = () => { + if (!videoRef.current || !canvasRef.current) return; + const video = videoRef.current; + const canvas = canvasRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const imgUrl = canvas.toDataURL("image/jpeg"); + setCapturedImage(imgUrl); + setShowCamera(false); + processOcr(imgUrl); + }; + + const processOcr = async (base64Image: string) => { + try { + setIsProcessing(true); + const file = await fetch(base64Image) + .then((res) => res.blob()) + .then( + (blob) => new File([blob], "capture.jpg", { type: "image/jpeg" }) + ); + + const data = await extractIdCardInfo(file); + const rrnRaw = data.residentId ?? ""; + const residentIdFront = rrnRaw.slice(0, 6); + const residentIdBack = rrnRaw[6] ? rrnRaw[6] + "******" : "*".repeat(7); + + setCapturedData({ + name: data.name ?? "", + residentIdFront, + residentIdBack, + issueDate: (data.issueDate ?? "").replaceAll(".", ""), + }); + + onNext(); + } catch (e) { + alert("OCR 인식 실패: " + (e as Error).message); + onRetry(); + } finally { + setIsProcessing(false); + } + }; + + const handleUpload = (file: File) => { + const reader = new FileReader(); + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + processOcr(dataUrl); + }; + reader.readAsDataURL(file); + }; + + return ( +
+
+
+

신분증 인식

+

+ 주민등록증을 촬영하거나 이미지를 업로드하세요. +
+ 신분증의 모든 정보가 선명하게 보이도록 해주세요. +

+
+ +
+ + + +
+
+ + {showCamera && ( +
+
+
+

신분증 촬영

+ +
+ +
+
+ +
+ + +
+
+
+ )} + + {isProcessing && ( +
+
+ +

OCR 인식 중...

+
+
+ )} +
+ ); +} diff --git a/app/signup/wallet/verify/components/ReviewStep.tsx b/app/signup/wallet/verify/components/ReviewStep.tsx new file mode 100644 index 0000000..bd7c995 --- /dev/null +++ b/app/signup/wallet/verify/components/ReviewStep.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { FC, RefObject } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; + +interface ReviewStepProps { + name: string; + residentIdFront: string; + residentIdBack: string; + issueDate: string; + onChangeName: (value: string) => void; + onChangeResidentIdFront: (value: string) => void; + onChangeResidentIdBack: (value: string) => void; + onChangeIssueDate: (value: string) => void; + onRetryCapture: () => void; + onSubmit: () => void; + isLoading: boolean; + residentIdBackRef: RefObject; + issueDateRef: RefObject; + success: string | null; + error: string | null; +} + +const ReviewStep: FC = ({ + name, + residentIdFront, + residentIdBack, + issueDate, + onChangeName, + onChangeResidentIdFront, + onChangeResidentIdBack, + onChangeIssueDate, + onRetryCapture, + onSubmit, + isLoading, + residentIdBackRef, + issueDateRef, + success, + error, + }) => { + return ( +
+
+

인식 결과 확인

+

+ 신분증에서 인식된 정보를 확인하고 필요한 경우 수정해주세요. +

+
+ +
+
+ + onChangeName(e.target.value)} + required + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + /> +
+ +
+ +
+ onChangeResidentIdFront(e.target.value)} + required + maxLength={6} + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + /> + - + onChangeResidentIdBack(e.target.value)} + required + maxLength={7} + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + ref={residentIdBackRef} + /> +
+
+ +
+ + onChangeIssueDate(e.target.value)} + required + maxLength={8} + className="h-12 rounded-xl border-[#E0E0E0] bg-white focus-visible:ring-[#FFD485] focus-visible:ring-offset-0" + ref={issueDateRef} + /> +

+ 주민등록증에 기재된 발급일자 8자리를 입력하세요. +

+
+
+ +
+ + +
+ + {success && ( +
+ {success} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ ); +}; + +export default ReviewStep; diff --git a/app/signup/wallet/verify/page.tsx b/app/signup/wallet/verify/page.tsx new file mode 100644 index 0000000..ebc8382 --- /dev/null +++ b/app/signup/wallet/verify/page.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { motion } from "framer-motion"; +import { ArrowLeft, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import CaptureStep from "@/app/signup/wallet/verify/components/CaptureStep"; +import ReviewStep from "@/app/signup/wallet/verify/components/ReviewStep"; + +export type VerificationStep = "input" | "capture" | "review"; + +export default function WalletVerifyPage() { + const router = useRouter(); + const [step, setStep] = useState("capture"); + + // 사용자 입력 상태 + const [name, setName] = useState(""); + const [residentIdFront, setResidentIdFront] = useState(""); + const [residentIdBack, setResidentIdBack] = useState(""); + const [issueDate, setIssueDate] = useState(""); + + // OCR 데이터 수신 여부 체크 + const [isCaptured, setIsCaptured] = useState(false); + + // 선택된 업로드 방식 상태 + const [selectedMethod, setSelectedMethod] = useState<"camera" | "upload" | null>(null); + + // UI 상태 + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // ref (자동 포커스 이동용) + const residentIdBackRef = useRef(null!); + const issueDateRef = useRef(null!); + + const handleFinalSubmit = async () => { + setIsLoading(true); + setError(null); + try { + await new Promise((res) => setTimeout(res, 1000)); + setSuccess("본인인증이 완료되었습니다."); + sessionStorage.setItem( + "verifiedName", + name // OCR로 추출한 이름 + ); + setTimeout(() => { + router.push("/signup/wallet/contact"); + }, 1200); + } catch (e) { + setError("본인인증에 실패했습니다. 다시 시도해주세요."); + } finally { + setIsLoading(false); + } + }; + + return ( + + {/* 헤더 */} +
+ + + 본인인증 + +
+ +
+
+
+ +

본인인증

+

+ 전자지갑 개설을 위해 본인인증이 필요합니다. +
정확한 정보를 입력해주세요. +

+
+ + {step === "capture" && ( + setStep("review")} + onRetry={() => setStep("capture")} + onBack={() => router.back()} + selectedMethod={selectedMethod} + setSelectedMethod={setSelectedMethod} + setCapturedData={(data: { name: string; residentIdFront: string; residentIdBack: string; issueDate: string }) => { + try { + setName(data.name ?? ""); + setResidentIdFront(data.residentIdFront ?? ""); + setResidentIdBack(data.residentIdBack ?? ""); + setIssueDate((data.issueDate ?? "").replaceAll(".", "")); + setIsCaptured(true); + } catch (e) { + alert("OCR 인식 실패: " + (e as Error).message); + setStep("capture"); + } + }} + /> + )} + + {step === "review" && ( + { + setName(val); + setIsCaptured(true); + }} + onChangeResidentIdFront={(val) => { + setResidentIdFront(val); + setIsCaptured(true); + }} + onChangeResidentIdBack={(val) => { + setResidentIdBack(val); + setIsCaptured(true); + }} + onChangeIssueDate={(val) => { + setIssueDate(val); + setIsCaptured(true); + }} + onRetryCapture={() => setStep("capture")} + onSubmit={handleFinalSubmit} + isLoading={isLoading} + residentIdBackRef={residentIdBackRef} + issueDateRef={issueDateRef} + success={success} + error={error} + /> + )} +
+
+
+ ); +} diff --git a/app/vouchers/components/ExpandableSection.tsx b/app/vouchers/components/ExpandableSection.tsx new file mode 100644 index 0000000..8bc6d96 --- /dev/null +++ b/app/vouchers/components/ExpandableSection.tsx @@ -0,0 +1,39 @@ +"use client" + +import { ReactNode, useState } from "react" +import { ChevronDown, ChevronUp } from "lucide-react" + +interface ExpandableSectionProps { + title: string + icon: ReactNode + children: ReactNode +} + +export function ExpandableSection({ title, icon, children }: ExpandableSectionProps) { + const [isOpen, setIsOpen] = useState(true) // 초기값을 true로 설정 + + const toggleOpen = () => { + setIsOpen((prev) => !prev) + } + + return ( +
+ + + {isOpen && ( +
+ {children} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/app/vouchers/components/HeaderImage.tsx b/app/vouchers/components/HeaderImage.tsx new file mode 100644 index 0000000..a397026 --- /dev/null +++ b/app/vouchers/components/HeaderImage.tsx @@ -0,0 +1,37 @@ +"use client" + +import Image from "next/image" +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" + +interface HeaderImageProps { + image: string + title: string + contact: string +} + +export default function HeaderImage({ image, title, contact }: HeaderImageProps) { + const router = useRouter() + + return ( +
+ {title|| +
+ + + + +
+ + {contact} + +

{title}

+
+
+ ) +} diff --git a/app/vouchers/components/Pagination.tsx b/app/vouchers/components/Pagination.tsx new file mode 100644 index 0000000..6c7b936 --- /dev/null +++ b/app/vouchers/components/Pagination.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; +import { useState } from "react"; +import { motion } from "framer-motion"; + +interface PaginationProps { + totalPages: number; + currentPage: number; + onPageChange: (page: number) => void; +} + +export default function Pagination({ totalPages, currentPage, onPageChange }: PaginationProps) { + const [jumpToPage, setJumpToPage] = useState("") + const [showJumpInput, setShowJumpInput] = useState(false) + + // 항상 최대 5개의 페이지 번호만 표시 + const getVisiblePages = () => { + // 총 페이지가 5개 이하면 모두 표시 + if (totalPages <= 5) { + return Array.from({ length: totalPages }, (_, i) => i) + } + + // 현재 페이지 주변 페이지 계산 + let startPage = Math.max(0, currentPage - 1) + let endPage = Math.min(totalPages - 1, currentPage + 1) + + // 표시할 페이지 수가 3개 미만이면 조정 + const visibleCount = endPage - startPage + 1 + if (visibleCount < 3) { + if (startPage === 0) { + endPage = Math.min(totalPages - 1, 2) + } else if (endPage === totalPages - 1) { + startPage = Math.max(0, totalPages - 3) + } + } + + return Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i) + } + + const visiblePages = getVisiblePages() + + // 큰 숫자를 간결하게 표시 (예: 1000 -> 1K) + const formatPageNumber = (pageNum: number) => { + const num = pageNum + 1 // 0-based to 1-based + if (num < 1000) return num.toString() + if (num < 10000) return `${(num / 1000).toFixed(1)}K` + if (num < 1000000) return `${Math.floor(num / 1000)}K` + return `${(num / 1000000).toFixed(1)}M` + } + + const handleJumpToPage = () => { + const pageNum = Number.parseInt(jumpToPage, 10) + if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) { + onPageChange(pageNum - 1) // Convert from 1-based to 0-based + } + setJumpToPage("") + setShowJumpInput(false) + } + + return ( +
+
+ + + +
+ {/* 첫 페이지 */} + {visiblePages[0] > 0 && ( + <> + + {visiblePages[0] > 1 && ( + + )} + + )} + + {/* 가운데 페이지들 */} + {visiblePages.map((page) => ( + onPageChange(page)} + className={`min-w-8 h-8 px-2 text-sm flex items-center justify-center rounded-md transition-all ${ + currentPage === page ? "bg-gray-200 text-gray-800 font-medium" : "text-gray-600 hover:bg-gray-100" + }`} + whileTap={{ scale: 0.95 }} + > + {formatPageNumber(page)} + + ))} + + {/* 마지막 페이지 */} + {visiblePages[visiblePages.length - 1] < totalPages - 1 && ( + <> + {visiblePages[visiblePages.length - 1] < totalPages - 2 && ( + + )} + + + )} +
+ + + +
+ + {/* 페이지 점프 입력 필드 */} + {showJumpInput && ( + + setJumpToPage(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleJumpToPage()} + placeholder={`1-${formatPageNumber(totalPages - 1)}`} + className="w-20 h-8 px-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-gray-400" + autoFocus + /> + + + + )} +
+ ) +} diff --git a/app/vouchers/components/PurchasePassword.tsx b/app/vouchers/components/PurchasePassword.tsx new file mode 100644 index 0000000..514ea0a --- /dev/null +++ b/app/vouchers/components/PurchasePassword.tsx @@ -0,0 +1,24 @@ +import { motion } from "framer-motion"; +import { forwardRef } from "react"; +import VirtualKeypad, { VirtualKeypadHandle } from "@/components/virtual-keypad"; // <-- import 추가 + +interface SimplePassWordProps { + onComplete: (password: string) => void; + isConfirm?: boolean; +} + +const PurchasePassword = forwardRef( + ({ onComplete, isConfirm = false }, ref) => { + return ( + + + + ); + } +); + +export default PurchasePassword; diff --git a/app/vouchers/components/PurchaseTokenBalance.tsx b/app/vouchers/components/PurchaseTokenBalance.tsx new file mode 100644 index 0000000..09e8768 --- /dev/null +++ b/app/vouchers/components/PurchaseTokenBalance.tsx @@ -0,0 +1,58 @@ +"use client" + +import { Wallet, ArrowUpRight } from "lucide-react" +import { motion } from "framer-motion" +import { useRouter } from "next/navigation" + +interface PurchaseTokenBalanceProps { + name: string + accountNumber: string + tokenBalance: number +} + +export default function PurchaseTokenBalance({ name, accountNumber, tokenBalance }: PurchaseTokenBalanceProps) { + const router = useRouter() + + return ( + router.push("/wallet")} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + > +
+
+ 지갑으로 이동 + +
+
+ +
+
+ +
+
+

+ {name} 님의 지갑 +

+

{accountNumber}

+
+
+ +
+
+
+

토큰 잔액

+
+

{tokenBalance.toLocaleString()}

+

+
+
+
+
+
+ ) +} diff --git a/app/vouchers/components/StoreList.tsx b/app/vouchers/components/StoreList.tsx new file mode 100644 index 0000000..7f95867 --- /dev/null +++ b/app/vouchers/components/StoreList.tsx @@ -0,0 +1,61 @@ +"use client" + +import { useEffect, useState } from "react" +import { StoreModal } from "./StoreModal" +import { getVoucherStores } from "@/lib/api/voucher" + +interface StoreListProps { + voucherId: number +} + +export function StoreList({ voucherId }: StoreListProps) { + const [previewStores, setPreviewStores] = useState([]) + const [isModalOpen, setIsModalOpen] = useState(false) + + useEffect(() => { + async function fetchPreviewStores() { + try { + const res = await getVoucherStores(voucherId, 0, 5) + console.log("🔍 받은 사용처 목록:", res.content) + + const names = res.content.map((store: any) => { + console.log("👉 store 객체:", store) + return store.storeName + }) + + setPreviewStores(names) + console.log("✅ previewStores 상태:", names) + } catch (error) { + console.error("사용처 불러오기 실패:", error) + setPreviewStores([]) + } + } + + fetchPreviewStores() +}, [voucherId]) + + return ( +
+
    + {previewStores.map((storeName, idx) => ( +
  • {storeName || "(이름 없음)"}
  • + ))} +
+ + {previewStores.length > 0 && ( +
+ +
+ )} + + {isModalOpen && ( + setIsModalOpen(false)} /> + )} +
+ ) +} diff --git a/app/vouchers/components/StoreModal.tsx b/app/vouchers/components/StoreModal.tsx new file mode 100644 index 0000000..a8eb378 --- /dev/null +++ b/app/vouchers/components/StoreModal.tsx @@ -0,0 +1,200 @@ +"use client" + +import { useEffect, useState } from "react" +import { X } from "lucide-react" +import { getVoucherStores } from "@/lib/api/voucher" +import Pagination from "@/app/vouchers/components/Pagination" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" +import { motion, AnimatePresence } from "framer-motion" + +interface StoreModalProps { + voucherId: number + onClose: () => void +} + +export function StoreModal({ voucherId, onClose }: StoreModalProps) { + const [stores, setStores] = useState([]) + const [page, setPage] = useState(0) + const [totalPages, setTotalPages] = useState(0) + const [totalCount, setTotalCount] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const pageSize = 10 + + const resetData = () => { + setStores([]) + setPage(0) + setTotalPages(0) + setTotalCount(0) + setIsLoading(true) + } + + useEffect(() => { + resetData() + }, [voucherId]) + + useEffect(() => { + let isAborted = false + + async function fetchStores() { + setIsLoading(true) + try { + const res = await getVoucherStores(voucherId, page, pageSize) + const names = res.content.map((store: any) => store.storeName) + + if (!isAborted) { + const calculatedTotalPages = Math.max(1, Math.ceil(res.totalElements / pageSize)) + const newTotalPages = + res.totalElements === 0 + ? 0 + : Math.min(calculatedTotalPages, res.totalPages || calculatedTotalPages) + + setTotalPages(newTotalPages) + setTotalCount(res.totalElements) + + if (page >= newTotalPages && newTotalPages > 0) { + setPage(0) + } else { + setStores(names) + } + } + } catch (error) { + if (!isAborted) { + console.error("전체 사용처 불러오기 실패:", error) + setStores([]) + setTotalPages(0) + setTotalCount(0) + } + } finally { + if (!isAborted) setIsLoading(false) + } + } + + fetchStores() + + return () => { + isAborted = true + } + }, [voucherId, page]) + + return ( + + + + {/* 헤더 */} +
+
+

전체 사용처

+ + {totalCount}개 {/* ✅ 전체 개수 표시 */} + +
+ +
+ + {/* 리스트 */} + +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, idx) => ( +
+ ))} +
+ ) : stores.length > 0 ? ( +
    + {stores.map((store, idx) => ( + +
    + {store} + + ))} +
+ ) : ( +
+
+ + + +
+

사용처 정보가 없습니다.

+

+ 등록된 사용처가 없거나 정보를 불러오는데 실패했습니다. +

+
+ )} +
+ + + {/* 페이지네이션 */} + {totalPages > 1 && ( + <> + +
+ { + if (newPage !== page && newPage >= 0 && newPage < totalPages) { + setPage(newPage) + } + }} + /> +
+ + )} + + + + ) +} diff --git a/app/vouchers/components/VoucherCard.tsx b/app/vouchers/components/VoucherCard.tsx new file mode 100644 index 0000000..2af0a3d --- /dev/null +++ b/app/vouchers/components/VoucherCard.tsx @@ -0,0 +1,101 @@ +"use client" + +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import Image from "next/image" +import { motion } from "framer-motion" +import type { Voucher } from "@/app/vouchers/types/voucher" + +interface Props { + voucher: Voucher + onCardClick?: (voucher: Voucher) => void + onActionClick?: (voucher: Voucher) => void + actionLabel?: string +} + +export default function VoucherCard({ + voucher, + onCardClick, + actionLabel = "구매하기", +}: Props) { + const router = useRouter() + + const handleCardClick = () => { + if (onCardClick) { + onCardClick(voucher) + } else { + router.push(`/vouchers/details/${voucher.id}`) + } + } + + const handleActionClick = (e: React.MouseEvent) => { + e.stopPropagation(); + router.push(`/vouchers/purchase?voucherId=${voucher.id}`); + }; + + const remainingCount = voucher.remainingCount; + const fillPercent = Math.round((100 * remainingCount) / voucher.totalCount); + + + return ( + +
+ {voucher.name} +
+ 남은 수량: {remainingCount}개 +
+
+
+
+
+ +
+

{voucher.name}

+

{voucher.description}

+
+
+

유효기간

+

+ {new Date(voucher.validDate).toLocaleDateString().replace(/\.$/, "")} +

+
+
+
+

+ {voucher.price.toLocaleString()}원 + 토큰가 +

+ {voucher.originalPrice && ( +

+ {voucher.originalPrice.toLocaleString()}원 +

+ )} +
+ +
+
+
+ + ) +} \ No newline at end of file diff --git a/app/vouchers/components/VoucherCategoryWithFilter.tsx b/app/vouchers/components/VoucherCategoryWithFilter.tsx new file mode 100644 index 0000000..b82fd37 --- /dev/null +++ b/app/vouchers/components/VoucherCategoryWithFilter.tsx @@ -0,0 +1,102 @@ +"use client" + +import { useState } from "react" +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Button } from "@/components/ui/button" +import { Filter } from "lucide-react" +import { motion } from "framer-motion" +import { useRouter, useSearchParams } from "next/navigation" + +const categories = [ + { id: "all", name: "전체" }, + { id: "FOOD", name: "음식" }, + { id: "MEDICAL", name: "의료" }, + { id: "SERVICE", name: "서비스" }, + { id: "TOURISM", name: "관광" }, + { id: "LODGING", name: "숙박" }, + { id: "EDUCATION", name: "교육" }, +] + +const sortOptions = [ + { id: "createdAt", label: "최신순", direction: "desc" }, + { id: "price", label: "금액 높은 순", direction: "desc" }, + { id: "price", label: "금액 낮은 순", direction: "asc" }, +] + +export default function VoucherCategoryWithFilter() { + const router = useRouter() + const searchParams = useSearchParams() + const storeCategory = searchParams.get("storeCategory") || "all" + const sort = searchParams.get("sort") || "createdAt" + const direction = searchParams.get("direction") || "desc" + const [open, setOpen] = useState(false) + + const handleCategoryChange = (value: string) => { + const params = new URLSearchParams(searchParams) + if (value === "all") { + params.delete("storeCategory") + } else { + params.set("storeCategory", value) + } + router.push(`/vouchers?${params.toString()}`) + } + + const handleSortChange = (sort: string, direction: string) => { + const params = new URLSearchParams(searchParams) + params.set("sort", sort) + params.set("direction", direction) + router.push(`/vouchers?${params.toString()}`) + } + + return ( +
+
+ + + {categories.map((c) => ( + + {c.name} + + ))} + + + + +
+ + + {open && ( +
+ {sortOptions.map((option) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/app/vouchers/components/VoucherHeader.tsx b/app/vouchers/components/VoucherHeader.tsx new file mode 100644 index 0000000..cc0d1a1 --- /dev/null +++ b/app/vouchers/components/VoucherHeader.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { ArrowLeft } from "lucide-react" + +export default function VoucherHeader({ title }: { title: string }) { + const router = useRouter() + + return ( +
+
+ +

{title}

+
+
+ ) +} diff --git a/app/vouchers/components/VoucherInfo.tsx b/app/vouchers/components/VoucherInfo.tsx new file mode 100644 index 0000000..fbfc251 --- /dev/null +++ b/app/vouchers/components/VoucherInfo.tsx @@ -0,0 +1,38 @@ +"use client" + +import { useRouter } from "next/navigation" +interface VoucherInfoProps { + amount: string + validDate: string + voucherId: number +} + +export function VoucherInfo({ amount, validDate, voucherId }: VoucherInfoProps) { + const router = useRouter() + + const handleApply = (e: React.MouseEvent) => { + e.stopPropagation(); + router.push(`/vouchers/purchase?voucherId=${voucherId}`); + }; + + return ( +
+
+
+

구매 금액

+

{amount}

+
+
+

유효기간

+

{validDate}

+
+
+ +
+ ) +} \ No newline at end of file diff --git a/app/vouchers/components/VoucherList.tsx b/app/vouchers/components/VoucherList.tsx new file mode 100644 index 0000000..5bb06a7 --- /dev/null +++ b/app/vouchers/components/VoucherList.tsx @@ -0,0 +1,99 @@ +"use client" + +import { useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import VoucherCard from "./VoucherCard" +import { getVouchers } from "@/lib/api/voucher" +import type { Voucher } from "@/app/vouchers/types/voucher" +import Pagination from "@/components/common/Pagination" +export default function VoucherList() { + const [vouchers, setVouchers] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const searchParams = useSearchParams() + + useEffect(() => { + const storeCategory = searchParams.get("storeCategory") + const sort = searchParams.get("sort") || "createdAt" + const direction = searchParams.get("direction") || "desc" + const searchKeyword = searchParams.get("searchKeyword") || "" + + const params: Record = { + sort, + direction, + page: page - 1, + size: 10, + } + + if (storeCategory && storeCategory !== "all") { + params.storeCategory = storeCategory.toUpperCase() + } + + if (searchKeyword) { + params.searchKeyword = searchKeyword + } + + setLoading(true) + getVouchers(params) + .then((res) => { + console.log("API Response:", res); + setVouchers(res.content); + setTotalPages(res.totalPages); + }) + .catch((error) => { + console.error("Error fetching vouchers:", error); + setVouchers([]); + setTotalPages(1); + }) + .finally(() => setLoading(false)); + }, [searchParams.toString(), page]) + + + + if (loading) { + return ( +
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) + } + + if (vouchers.length === 0) { + return

검색 결과가 없습니다.

+ } + + return ( +
+
+ {vouchers.map((voucher) => ( + + ))} +
+ + +
+ ) +} diff --git a/app/vouchers/components/VoucherPurchaseCard.tsx b/app/vouchers/components/VoucherPurchaseCard.tsx new file mode 100644 index 0000000..c4a24c3 --- /dev/null +++ b/app/vouchers/components/VoucherPurchaseCard.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import { motion } from "framer-motion"; +import type { Voucher } from "@/app/vouchers/types/voucher"; + +interface Props { + voucher: Voucher; + onCardClick?: (voucher: Voucher) => void; +} + +export default function VoucherPurchaseCard({ voucher, onCardClick }: Props) { + const router = useRouter(); + + const handleCardClick = () => { + if (onCardClick) { + onCardClick(voucher); + } else { + router.push(`/vouchers/details/${voucher.id}`); + } + }; + + const remainingCount = voucher.remainingCount; + const fillPercent = Math.round( + (100 * (voucher.totalCount - remainingCount)) / voucher.totalCount + ); + + return ( + +
+ {voucher.name} +
+ 남은 수량: {remainingCount}개 +
+
+
+
+
+ +
+

+ {voucher.name} +

+

{voucher.description}

+ +
+
+

유효기간

+

+ {new Date(voucher.validDate) + .toLocaleDateString() + .replace(/\.$/, "")} +

+
+
+

+ {voucher.price.toLocaleString()}원 + + 토큰가 + +

+ {voucher.originalPrice && ( +

+ {voucher.originalPrice.toLocaleString()}원 +

+ )} +
+
+
+ + ); +} diff --git a/app/vouchers/components/VoucherPurchaseHeader.tsx b/app/vouchers/components/VoucherPurchaseHeader.tsx new file mode 100644 index 0000000..9a4736d --- /dev/null +++ b/app/vouchers/components/VoucherPurchaseHeader.tsx @@ -0,0 +1,29 @@ +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; + +interface HeaderProps { + title: string; + backHref?: string; + onBack?: () => void; +} + +export default function Header({ title, backHref, onBack }: HeaderProps) { + const handleBack = () => { + if (onBack) { + onBack(); + } else if (backHref) { + window.location.href = backHref; + } else { + window.history.back(); + } + }; + + return ( +
+ +

{title}

+
+ ); +} \ No newline at end of file diff --git a/app/vouchers/components/VoucherSearchBar.tsx b/app/vouchers/components/VoucherSearchBar.tsx new file mode 100644 index 0000000..d367683 --- /dev/null +++ b/app/vouchers/components/VoucherSearchBar.tsx @@ -0,0 +1,46 @@ +"use client" + +import { Input } from "@/components/ui/input" +import { Search } from "lucide-react" +import { useRouter, useSearchParams } from "next/navigation" +import { useEffect, useState } from "react" + +export default function VoucherSearchBar() { + const router = useRouter() + const searchParams = useSearchParams() + const initialValue = searchParams.get("searchKeyword") || "" + + const [keyword, setKeyword] = useState(initialValue) + const [debounced, setDebounced] = useState(initialValue) + + // 디바운싱 + useEffect(() => { + const timer = setTimeout(() => { + setDebounced(keyword) + }, 1000) + return () => clearTimeout(timer) + }, [keyword]) + + // 디바운싱된 값 URL 반영 + useEffect(() => { + const params = new URLSearchParams(searchParams) + if (debounced) { + params.set("searchKeyword", debounced) + } else { + params.delete("searchKeyword") + } + router.push(`/vouchers?${params.toString()}`) + }, [debounced]) + + return ( +
+ + setKeyword(e.target.value)} + /> +
+ ) +} diff --git a/app/vouchers/details/[id]/loading/Skeleton.tsx b/app/vouchers/details/[id]/loading/Skeleton.tsx new file mode 100644 index 0000000..1af7a3a --- /dev/null +++ b/app/vouchers/details/[id]/loading/Skeleton.tsx @@ -0,0 +1,81 @@ +export function VoucherDetailSkeleton() { + return ( +
+ {/* HeaderImage Placeholder - 실제 HeaderImage와 동일한 높이 */} +
+
+
+
+
+
+
+ +
+ {/* VoucherInfo Placeholder */} +
+
+
+
+
+
+ + {/* 상세 설명 ExpandableSection */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* 사용처 ExpandableSection */} +
+
+
+
+
+
+
+
+
+ {/* StoreList 스켈레톤 */} +
+
+
+
+
+
+
+ + {/* 환불 정책 ExpandableSection */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* 문의처 Section */} +
+
+
+
+
+
+ ) +} diff --git a/app/vouchers/details/[id]/page.tsx b/app/vouchers/details/[id]/page.tsx new file mode 100644 index 0000000..cf319b4 --- /dev/null +++ b/app/vouchers/details/[id]/page.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import { getVoucherDetails } from "@/lib/api/voucher" +import HeaderImage from "@/app/vouchers/components/HeaderImage" +import { VoucherInfo } from "@/app/vouchers/components/VoucherInfo" +import { ExpandableSection } from "@/app/vouchers/components/ExpandableSection" +import { StoreList } from "@/app/vouchers/components/StoreList" +import { FileText, Building, CreditCard } from "lucide-react" +import type { VoucherDetail } from "@/app/vouchers/types/voucher" +import {VoucherDetailSkeleton} from "@/app/vouchers/details/[id]/loading/Skeleton" + +export default function VoucherDetailPage() { + const { id } = useParams<{ id: string }>() + const router = useRouter() + + const [voucher, setVoucher] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + + useEffect(() => { + if (!id) return + const fetchData = async () => { + try { + const data = await getVoucherDetails(Number(id)) + setVoucher(data) + } catch (e) { + console.error("바우처 조회 실패", e) + setError(true) + } finally { + setLoading(false) + } + } + fetchData() + }, [id]) + + if (loading) return + if (error || !voucher) return
바우처를 찾을 수 없습니다.
+ + return ( +
+ +
+ + } + > +

{voucher.detailDescription}

+
+ } + > + + + } + > +

{voucher.refundPolicy}

+
+
+

문의처

+

{voucher.contact}

+
+
+
+ ) +} diff --git a/app/vouchers/page.tsx b/app/vouchers/page.tsx new file mode 100644 index 0000000..c2d54d7 --- /dev/null +++ b/app/vouchers/page.tsx @@ -0,0 +1,31 @@ +import { Suspense } from "react" +import VoucherHeader from "@/app/vouchers/components/VoucherHeader" +import VoucherList from "@/app/vouchers/components/VoucherList" +import VoucherCategoryWithFilter from "@/app/vouchers/components/VoucherCategoryWithFilter" +import VoucherSearchBar from "@/app/vouchers/components/VoucherSearchBar" + +export default function VouchersPage() { + return ( +
+ + +
+ Loading...
}> + + +
+ Loading...
}> + + +
+
+ +
+

전체 바우처

+ Loading...
}> + + +
+
+ ) +} diff --git a/app/vouchers/purchase/page.tsx b/app/vouchers/purchase/page.tsx new file mode 100644 index 0000000..a792372 --- /dev/null +++ b/app/vouchers/purchase/page.tsx @@ -0,0 +1,240 @@ +"use client" + +import { useRouter, useSearchParams } from "next/navigation" +import { useState, useEffect, Suspense } from "react" +import { motion } from "framer-motion" +import { ArrowRight, ShieldCheck, CheckCircle2, CreditCard, Clock } from "lucide-react" +import VoucherPurchaseCard from "@/app/vouchers/components/VoucherPurchaseCard" +import PurchaseTokenBalance from "@/app/vouchers/components/PurchaseTokenBalance" +import { Button } from "@/components/ui/button" +import { getVoucherDetails, getMyVouchers } from "@/lib/api/voucher" +import { fetchWalletInfo } from "@/app/dashboard/api/wallet-info" +import type { MyVoucher, MyVoucherDetail } from "@/app/my-vouchers/types/my-voucher" +import Header from "@/app/vouchers/components/VoucherPurchaseHeader" + +function PurchaseContent() { + const router = useRouter() + const params = useSearchParams() + const voucherId = Number(params.get("voucherId")) + + const [voucher, setVoucher] = useState(null) + const [methods, setMethods] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(null) + const [isHovering, setIsHovering] = useState(false) + + const [walletInfo, setWalletInfo] = useState<{ + name: string + accountNumber: string + tokenBalance: number + } | null>(null) + const [loadingWallet, setLoadingWallet] = useState(true) + const [walletError, setWalletError] = useState(null) + + useEffect(() => { + if (voucherId) { + getVoucherDetails(voucherId).then((data) => setVoucher(data as unknown as MyVoucherDetail)) + } + getMyVouchers().then((res) => setMethods(res.content)) + + fetchWalletInfo() + .then((data) => + setWalletInfo({ + name: data.name, + accountNumber: data.accountNumber, + tokenBalance: data.tokenBalance, + }), + ) + .catch(() => setWalletError("지갑 정보를 불러올 수 없습니다.")) + .finally(() => setLoadingWallet(false)) + }, [voucherId]) + + const handlePay = () => { + if (!voucher) return + router.push(`/vouchers/purchase/verify?voucherId=${voucher.id}`) + } + + if (!voucher) { + return ( +
+
+
+
+
+
+
+
+
+
+ ) + } + + const hasEnoughBalance = walletInfo && walletInfo.tokenBalance >= voucher.price + + return ( +
+
+
+ {/* Progress Steps */} +
+
+
+ +
+ 상품 선택 +
+
+
+
+ +
+ 결제 확인 +
+
+
+
+ +
+ 결제 완료 +
+
+ + + {/* Header */} +
+

구매 확인

+

구매 정보를 확인하고 결제를 진행해주세요

+
+ + {/* 1. 바우처 카드 */} + +
+

+ + 1 + + 구매 상품 +

+ +
+ + {/* 2. 토큰 잔액 */} + +
+

+ + 2 + + 결제 정보 +

+ {loadingWallet ? ( +
+ ) : walletError ? ( +
{walletError}
+ ) : ( + walletInfo && ( + + ) + )} +
+ + {/* 3. 결제 요약 */} + +
+

+ + 3 + + 결제 요약 +

+
+
+ 상품 금액 + {voucher.price.toLocaleString()}원 +
+
+
+ 총 결제 금액 + {voucher.price.toLocaleString()}원 +
+
+
+
+ + {/* 4. 결제 버튼 */} + setIsHovering(true)} + onHoverEnd={() => setIsHovering(false)} + > + + + {!hasEnoughBalance && walletInfo && ( +

잔액이 부족합니다. 충전이 필요합니다.

+ )} +
+ + {/* 5. 안내 메시지 */} +
+ + 안전한 결제를 위해 개인정보는 암호화되어 처리됩니다 +
+
+
+
+ ) +} + +export default function PurchasePage() { + return ( + Loading...
}> + + + ) +} diff --git a/app/vouchers/purchase/verify/page.tsx b/app/vouchers/purchase/verify/page.tsx new file mode 100644 index 0000000..62ceb19 --- /dev/null +++ b/app/vouchers/purchase/verify/page.tsx @@ -0,0 +1,105 @@ +"use client" + +import { useRouter, useSearchParams } from "next/navigation" +import { useRef, useState, Suspense } from "react" +import PurchasePassword from "../../components/PurchasePassword" +import { purchaseVoucher } from "@/lib/api/voucher" +import { verifySimplePassword } from "@/app/payment/api/payment" +import type { VirtualKeypadHandle } from "@/components/virtual-keypad" +import Image from "next/image" +import { ArrowLeft } from "lucide-react" +import LoadingOverlay from "@/components/common/LoadingOverlay" + +function VoucherPurchaseVerifyContent() { + const router = useRouter() + const params = useSearchParams() + const voucherId = Number(params.get("voucherId")) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [failCount, setFailCount] = useState(0) + const keypadRef = useRef(null) + + // 비밀번호 입력 완료 핸들러 + const handleComplete = async (input: string) => { + if (!input) return + setError(null) + setLoading(true) + + try { + const valid = await verifySimplePassword(input) + + // 비밀번호 검증 실패 시 + if (!valid) { + // 비밀번호가 일치하지 않으면 비밀번호 입력 필드 초기화 + keypadRef.current?.reset() + setFailCount((prev) => { + const next = prev + 1 + if (next >= 5) { + router.push("/mypage/reset-pin") + } + return next + }) + + setError("비밀번호가 일치하지 않습니다. 다시 입력해주세요.") + return + } + + // 비밀번호가 일치하면 바우처 구매 API 호출 + await purchaseVoucher({ voucherId, simplePassword: input }) + router.push("/wallet/voucher/purchase") + } catch (e) { + console.error(e) + setError("구매에 실패했습니다. 다시 시도해주세요.") + } finally { + setLoading(false) + } + } + + // 비밀번호 찾기 핸들러 + const handleForgotPassword = () => { + router.push("/mypage/change-pin") + } + + return ( +
+ {loading && } +
+ {/* Header */} +
+ + 비밀번호 입력 +
+ + 바우처 마스코트 + +

간편 비밀번호 입력

+

바우처 구매를 위해 간편 비밀번호를 입력해주세요.

+ + {failCount > 0 &&

오류 {failCount}/5

} + +
+ +
+ + {error &&

{error}

} + +

+ 비밀번호를 잊으셨나요? +

+
+
+ ) +} + +export default function VoucherPurchaseVerifyPage() { + return ( + Loading...
}> + + + ) +} diff --git a/app/vouchers/types/voucher.ts b/app/vouchers/types/voucher.ts new file mode 100644 index 0000000..f3e1013 --- /dev/null +++ b/app/vouchers/types/voucher.ts @@ -0,0 +1,55 @@ +import { StoreResponse } from '@/types/store' +export interface Voucher { + id: number + name: string + description: string + price: number + originalPrice?: number + validDate: string + contact?: string + remainingCount: number + totalCount: number + imageUrl: string + merchant?: string +} + +export interface VoucherDetail { + id: number + name: string + price: number + originalPrice: number + remainingCount: number + validDate: string // 백엔드에서 yyyy-MM-dd 형식으로 보내므로 string + detailDescription: string + refundPolicy: string + contact: string | null + imageUrl: string | null + stores: { + content: StoreResponse[] + totalPages: number + totalElements: number + size: number + number: number + first: boolean + last: boolean + } +} + +export interface VoucherSearchParams { + storeCategory?: string; + searchKeyword?: string; + sort?: string; + direction?: string; + page?: number; + size?: number; +} + +export interface VoucherPurchaseRequest { + voucherId: number + simplePassword: string +} + +export interface VoucherPurchaseResponse { + ownershipId: number + message: string +} \ No newline at end of file diff --git a/app/wallet/api/fetch-transactions-detail.ts b/app/wallet/api/fetch-transactions-detail.ts new file mode 100644 index 0000000..96368a0 --- /dev/null +++ b/app/wallet/api/fetch-transactions-detail.ts @@ -0,0 +1,27 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export interface TransactionDetail { + id: number; + type: "PAYMENT" | "CHARGE" | "CONVERT"; + amount: number; + displayDescription: string; + createdAt: string; + txHash: string; +} + +export async function fetchTransactionDetail(id: string): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/wallet/transactions/${id}`, { + credentials: "include", + }); + + const data = await res.json(); + + if (!data.isSuccess) { + throw new Error(data.message || "거래 상세 조회 실패"); + } + + return data.result; +} diff --git a/app/wallet/api/fetch-transactions.ts b/app/wallet/api/fetch-transactions.ts new file mode 100644 index 0000000..fa0ff6b --- /dev/null +++ b/app/wallet/api/fetch-transactions.ts @@ -0,0 +1,34 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export interface Transaction { + id: number; + type: "PAYMENT" | "CONVERT"; + amount: number; + createdAt: string; + displayDescription: string; +} + +export async function fetchTransactions(): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/wallet/transactions`, { + method: "GET", + credentials: "include", + }); + + if (!res.ok) { + throw new Error(`HTTP error: ${res.status}`); + } + + const text = await res.text(); + const data = text ? JSON.parse(text) : null; + + console.log(data); + + if (data?.isSuccess) { + return data.result; + } else { + throw new Error(data?.message || "거래내역 조회 실패"); + } +} diff --git a/app/wallet/api/fetch-txhash-details.ts b/app/wallet/api/fetch-txhash-details.ts new file mode 100644 index 0000000..10d3da4 --- /dev/null +++ b/app/wallet/api/fetch-txhash-details.ts @@ -0,0 +1,17 @@ +import { BlockchainTransaction } from "@/app/wallet/blockchain-details/types/blockchain" +import {fetchWithAuth} from "@/lib/fetchWithAuth"; +import {getApiUrl} from "@/lib/getApiUrl"; + +const API_URL = getApiUrl(); + +export async function fetchTxDetail(txHash: string): Promise { + const res = await fetchWithAuth(`${API_URL}/api/users/wallet/tx/${txHash}`) + + const data = await res.json() + + if (!res.ok || !data.isSuccess) { + throw new Error(data.message || "트랜잭션 조회 실패") + } + + return data.result as BlockchainTransaction +} \ No newline at end of file diff --git a/app/wallet/api/wallet.ts b/app/wallet/api/wallet.ts new file mode 100644 index 0000000..db372f2 --- /dev/null +++ b/app/wallet/api/wallet.ts @@ -0,0 +1,98 @@ +import { getApiUrl } from "@/lib/getApiUrl"; +import { getCookie } from "@/lib/cookies"; +import {fetchWithAuth} from "@/lib/fetchWithAuth"; + +const API_URL = getApiUrl(); + +export async function fetchWalletBalance() { + const token = getCookie("accessToken"); + if (!token) throw new Error("accessToken 없음"); + + const response = await fetchWithAuth(`${API_URL}/api/users/wallet/balance`, { + headers: { + Authorization: `Bearer ${token}`, + }, + credentials: "include", + }); + + const data = await response.json(); + + if (!data.isSuccess) throw new Error(data.message || "잔액 조회 실패"); + + return { + depositBalance: data.result.depositBalance, + tokenBalance: data.result.tokenBalance, + }; +} + +export async function fetchWalletTransactions() { + const token = getCookie("accessToken"); + if (!token) throw new Error("accessToken 없음"); + + const res = await fetch(`${API_URL}/api/users/wallet/transactions`, { + headers: { + Authorization: `Bearer ${token}`, + }, + credentials: "include", + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`거래내역 요청 실패: ${res.status} - ${errText}`); + } + + const data = await res.json(); + return data.result; +} + +export async function verifyPassword(simplePassword: string) { + const token = getCookie("accessToken"); + if (!token) throw new Error("accessToken 없음"); + + const res = await fetchWithAuth(`${API_URL}/api/users/simple-password/verify`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + credentials: "include", + body: JSON.stringify({ simplePassword: simplePassword }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.message || "비밀번호가 올바르지 않습니다."); + } +} + +export async function convertBalance( + type: "deposit-to-token" | "token-to-deposit", + amount: number, + simplePassword: string +) { + const token = getCookie("accessToken"); + if (!token) throw new Error("accessToken 없음"); + + const endpoint = + type === "deposit-to-token" + ? "/api/users/wallet/convert/deposit-to-token" + : "/api/users/wallet/convert/token-to-deposit"; + + const response = await fetchWithAuth(`${API_URL}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + credentials: "include", + body: JSON.stringify({ amount, simplePassword }), + }); + + const data = await response.json(); + + if (!data.isSuccess) { + throw new Error(data.message || "전환 실패"); + } + + return data; +} \ No newline at end of file diff --git a/app/wallet/blockchain-details/[txHash]/page.tsx b/app/wallet/blockchain-details/[txHash]/page.tsx new file mode 100644 index 0000000..76cfa6c --- /dev/null +++ b/app/wallet/blockchain-details/[txHash]/page.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" +import { useToast } from "@/hooks/use-toast" +import { fetchTxDetail } from "../../api/fetch-txhash-details" +import BlockchainDetailsLoading from "../components/BlockchainDetailsLoading" +import { HeaderSection } from "../components/HeaderSection" +import {TransactionDetailCard} from "@/app/wallet/blockchain-details/components/TransactionDetailCard"; +import {BlockchainTransaction} from "@/app/wallet/blockchain-details/types/blockchain"; +import MascotCardSection from "@/app/wallet/blockchain-details/components/MascotCardSection"; + +export default function BlockchainDetailsPage() { + const { txHash } = useParams() + const { toast } = useToast() + const [transaction, setTransaction] = useState(null) + + useEffect(() => { + if (!txHash || typeof txHash !== "string") { + toast({ title: "txHash 누락", description: "유효한 트랜잭션 해시가 필요합니다." }) + return + } + + const fetchData = async () => { + try { + const tx = await fetchTxDetail(txHash) + setTransaction(tx) + } catch (error: any) { + toast({ title: "트랜잭션 조회 실패", description: error.message }) + } + } + + fetchData() + }, [txHash]) + + if (!transaction) { + return + } + + return ( +
+ +
+ + +
+
+ ) +} diff --git a/app/wallet/blockchain-details/components/BlockchainDetailsLoading.tsx b/app/wallet/blockchain-details/components/BlockchainDetailsLoading.tsx new file mode 100644 index 0000000..f3b485a --- /dev/null +++ b/app/wallet/blockchain-details/components/BlockchainDetailsLoading.tsx @@ -0,0 +1,40 @@ +export default function BlockchainDetailsLoading() { + return ( +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+ +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+ ))} +
+
+
+ ) +} diff --git a/app/wallet/blockchain-details/components/HeaderSection.tsx b/app/wallet/blockchain-details/components/HeaderSection.tsx new file mode 100644 index 0000000..a599ae8 --- /dev/null +++ b/app/wallet/blockchain-details/components/HeaderSection.tsx @@ -0,0 +1,20 @@ +import { ArrowLeft } from "lucide-react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" + +export function HeaderSection() { + const router = useRouter() + + return ( +
+
+
+ +

블록체인 상세

+
+
+
+ ) +} diff --git a/app/wallet/blockchain-details/components/MascotCardSection.tsx b/app/wallet/blockchain-details/components/MascotCardSection.tsx new file mode 100644 index 0000000..a2cd385 --- /dev/null +++ b/app/wallet/blockchain-details/components/MascotCardSection.tsx @@ -0,0 +1,25 @@ +import {Shield} from "lucide-react"; + +import Image from 'next/image'; + +export default function MascotCardSection() { + return ( +
+ {/* 마스코트 보안 인증 섹션 */} +
+
+
+ 토킷 마스코트 +
+ +
+
+
+
+

거래 검증 완료

+

토킷이 블록체인에서 안전하게 검증했어요

+
+
+
+ ) +} \ No newline at end of file diff --git a/app/wallet/blockchain-details/components/TransactionDetailCard.tsx b/app/wallet/blockchain-details/components/TransactionDetailCard.tsx new file mode 100644 index 0000000..c2420ee --- /dev/null +++ b/app/wallet/blockchain-details/components/TransactionDetailCard.tsx @@ -0,0 +1,177 @@ +"use client" + +import { useState } from "react" +import { Badge } from "@/components/ui/badge" +import { CheckCircle, Clock } from "lucide-react" +import { BlockchainTransaction } from "../types/blockchain" + +interface Props { + transaction: BlockchainTransaction +} + +export function TransactionDetailCard({ transaction }: Props) { + const [showMoreDetails, setShowMoreDetails] = useState(false) + + return ( +
+ {/* 거래 해시 */} + + + {/* 상태 */} + + + {/* 블록 */} + + + {/* 타임스탬프 */} + } + value={transaction.timestamp} + /> + + {/* From / To */} + + + + {/* 금액 */} + {transaction.value}} /> + + {/* + 더보기 */} +
+ + + {showMoreDetails && ( +
+ + + + +
+ )} +
+
+ ) +} + +// === 내부 컴포넌트 === + +function DetailRow({ + label, + value, + background = false, + color = "default", + }: { + label: string + value: string + background?: boolean + color?: "orange" | "default" +}) { + return ( +
+ +
+ {value} +
+
+ ) +} + +function DetailRowStatus({ label, status }: { label: string; status: string }) { + return ( +
+ + + + 성공 + +
+ ) +} + +function DetailRowBlock({ label, block, confirmations }: { + label: string + block: number + confirmations: number +}) { + return ( +
+ +
+ + {block} + {confirmations} 확인 +
+
+ ) +} + +function DetailRowIcon({ label, icon, value }: { + label: string + icon: React.ReactNode + value: string +}) { + return ( +
+ +
+ {icon} + {value} +
+
+ ) +} + +function SimpleRow({ label, right, background = false }: { + label: string + right: React.ReactNode + background?: boolean +}) { + return ( +
+ + {right} +
+ ) +} + +function DetailInfo({ label, value }: { label: string; value: string | number }) { + return ( +
+ {label}: + {value} +
+ ) +} + +function LabelWithIcon({ text }: { text: string }) { + return ( +
+
+ ? +
+ {text}: +
+ ) +} diff --git a/app/wallet/blockchain-details/types/blockchain.ts b/app/wallet/blockchain-details/types/blockchain.ts new file mode 100644 index 0000000..f5da535 --- /dev/null +++ b/app/wallet/blockchain-details/types/blockchain.ts @@ -0,0 +1,16 @@ +export interface BlockchainTransaction { + id: string + hash: string + status: "success" | "pending" | "failed" + block: number + confirmations: number + timestamp: string + from: string + to: string + value: string + fee: string + gasPrice: string + gasUsed: number + gasLimit: number + nonce: number +} diff --git a/app/wallet/components/AmountStep.tsx b/app/wallet/components/AmountStep.tsx new file mode 100644 index 0000000..6ffdfdc --- /dev/null +++ b/app/wallet/components/AmountStep.tsx @@ -0,0 +1,98 @@ +"use client" + +import type React from "react" + +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" +import BalanceCard from "@/app/wallet/components/convert/BalanceCard" +import AmountInput from "@/components/common/AmountInput" +import InfoBox from "@/app/wallet/components/common/InfoBox" +import { useMemo } from "react" + +interface AmountStepProps { + type: "deposit-to-token" | "token-to-deposit" + amount: string + depositBalance: number + tokenBalance: number + setAmount: React.Dispatch> + onMax: () => void + onChange: (val: string) => void + onContinue: () => void +} + +export default function AmountStep({ + type, + amount, + depositBalance, + tokenBalance, + setAmount, + onMax, + onChange, + onContinue, +}: AmountStepProps) { + const isDepositToToken = type === "deposit-to-token" // 예금 → 토큰 + const currentBalance = isDepositToToken ? depositBalance : tokenBalance + + const infoText = isDepositToToken + ? "토큰을 예금으로 전환하면 즉시 반영됩니다. 예금은 언제든지 다시 토큰으로 전환할 수 있습니다." + : "예금을 토큰으로 전환하면 즉시 반영됩니다. 토큰은 결제, 송금 등에 사용할 수 있으며, 언제든지 다시 예금으로 전환할 수 있습니다." + + const handleMaxAmount = () => { + const max = currentBalance + setAmount(String(max)) + } + + // 잔액 초과 여부 확인 + const isExceedingBalance = useMemo(() => { + const numericAmount = Number(amount) + return !isNaN(numericAmount) && numericAmount > currentBalance + }, [amount, currentBalance]) + + // 버튼 비활성화 조건 + const isDisabled = !amount || Number(amount) <= 0 || isNaN(Number(amount)) || isExceedingBalance + + return ( +
+ + {/* 상단 콘텐츠 */} +
+
+ +
+ + + 잔액을 초과했습니다 + + ) + } + /> +
+ + {/* 하단 콘텐츠 - 절대 위치로 하단 고정 */} +
+
+ {infoText} +
+ + +
+
+
+ ) +} diff --git a/app/wallet/components/CompleteStep.tsx b/app/wallet/components/CompleteStep.tsx new file mode 100644 index 0000000..e4b5c58 --- /dev/null +++ b/app/wallet/components/CompleteStep.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import { LoaderCircle, CheckCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import BalanceCard from "@/app/wallet/components/convert/BalanceCard"; +import confetti from "canvas-confetti"; + +interface CompleteStepProps { + type: "deposit-to-token" | "token-to-deposit"; + amount: string; + depositBalance: number; + tokenBalance: number; + onBackToWallet: () => void; +} + +export default function CompleteStep({ + type, + amount, + depositBalance, + tokenBalance, + onBackToWallet, +}: CompleteStepProps) { + const [done, setDone] = useState(false); + const parsedAmount = Number(amount); + const isDepositToToken = type === "deposit-to-token"; + + useEffect(() => { + if (!done) return; + + const duration = 3 * 1000; + const animationEnd = Date.now() + duration; + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 }; + + function randomInRange(min: number, max: number) { + return Math.random() * (max - min) + min; + } + + const interval = setInterval(() => { + const timeLeft = animationEnd - Date.now(); + if (timeLeft <= 0) return clearInterval(interval); + + const particleCount = 50 * (timeLeft / duration); + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, + }); + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, + }); + }, 450); + + return () => clearInterval(interval); + }, [done]); + + return ( + + {!done ? ( + setDone(true)} + className="mb-6" + > + + + ) : ( + + + + )} + +

+ 변환 완료 +

+

+ {parsedAmount.toLocaleString()}원이{" "} + {isDepositToToken ? "토큰으로" : "예금으로"} 변환되었습니다. +

+ +
+ +
+ + +
+ ); +} diff --git a/app/wallet/components/ConfirmStep.tsx b/app/wallet/components/ConfirmStep.tsx new file mode 100644 index 0000000..f79f015 --- /dev/null +++ b/app/wallet/components/ConfirmStep.tsx @@ -0,0 +1,63 @@ +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import ConvertPreview from "@/app/wallet/components/convert/ConvertPreview"; +import InfoBox from "@/app/wallet/components/common/InfoBox"; + +interface ConfirmStepProps { + type: "deposit-to-token" | "token-to-deposit"; + amount: string; + depositBalance: number; + tokenBalance: number; + onBack: () => void; + onConfirm: () => void; +} + +export default function ConfirmStep({ + type, + amount, + depositBalance, + tokenBalance, + onBack, + onConfirm, +}: ConfirmStepProps) { + return ( + +
+
+
+ +
+ + + 전환 후에는 취소할 수 없으며, 다시 토큰으로 전환하려면 별도의 절차가 + 필요합니다. + +
+ +
+ + +
+
+
+ ); +} diff --git a/app/wallet/components/ProcessingStep.tsx b/app/wallet/components/ProcessingStep.tsx new file mode 100644 index 0000000..eeb5a01 --- /dev/null +++ b/app/wallet/components/ProcessingStep.tsx @@ -0,0 +1,40 @@ +import { motion } from "framer-motion"; +import Image from "next/image"; + +interface ProcessingStepProps { + type: "deposit-to-token" | "token-to-deposit"; +} + +export default function ProcessingStep({ type }: ProcessingStepProps) { + const isDepositToToken = type === "deposit-to-token"; + + const imageSrc = isDepositToToken + ? "/images/deposit-to-token.gif" + : "/images/token-to-deposit.gif"; + + const altText = isDepositToToken + ? "예금을 토큰으로 전환" + : "토큰을 예금으로 전환"; + + return ( + +
+
+ {altText} +
+

전환중입니다.

+

잠시만 기다려주세요...

+
+
+ ); +} diff --git a/app/wallet/components/common/ConvertButton.tsx b/app/wallet/components/common/ConvertButton.tsx new file mode 100644 index 0000000..8bbd756 --- /dev/null +++ b/app/wallet/components/common/ConvertButton.tsx @@ -0,0 +1,45 @@ +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; +import { MoveRight } from "lucide-react"; + +export default function ConvertButton() { + const router = useRouter(); + + return ( +
+ + + +
+ ); +} diff --git a/app/wallet/components/common/InfoBox.tsx b/app/wallet/components/common/InfoBox.tsx new file mode 100644 index 0000000..1c5682a --- /dev/null +++ b/app/wallet/components/common/InfoBox.tsx @@ -0,0 +1,16 @@ +import { AlertCircle } from "lucide-react"; + +interface InfoBoxProps { + children: React.ReactNode; +} + +export default function InfoBox({ children }: InfoBoxProps) { + return ( +
+
+ +
{children}
+
+
+ ); +} diff --git a/app/wallet/components/common/TransactionCardContent.tsx b/app/wallet/components/common/TransactionCardContent.tsx new file mode 100644 index 0000000..e6591a7 --- /dev/null +++ b/app/wallet/components/common/TransactionCardContent.tsx @@ -0,0 +1,95 @@ +import { + ArrowDownUp, + ArrowDown, + ArrowUp, + Repeat, + ShoppingCart, + CornerUpLeft, + Gift, +} from "lucide-react"; + +interface Props { + displayDescription: string; + amount: number; + createdAt: string; + type: string; + disableClick?: boolean; +} + +export default function TransactionCardContent({ + displayDescription, + amount, + createdAt, + type, + }: Props) { + + const typeMeta = { + DEPOSIT: { + icon: , + bg: "bg-green-100", + color: "text-green-500", + sign: "+", + }, + WITHDRAW: { + icon: , + bg: "bg-red-100", + color: "text-red-500", + sign: "-", + }, + CONVERT: { + icon: , + bg: "bg-blue-100", + color: "text-blue-500", + sign: "", // 변환은 부호 없이 + }, + PURCHASE: { + icon: , + bg: "bg-red-100", + color: "text-red-500", + sign: "-", + }, + REFUND: { + icon: , + bg: "bg-green-100", + color: "text-green-500", + sign: "+", + }, + RECEIVE: { + icon: , + bg: "bg-yellow-100", + color: "text-yellow-500", + sign: "+", + }, + }[type] || { + icon: , + bg: "bg-gray-100", + color: "text-gray-500", + sign: "", + }; + + return ( +
+
+
+ {typeMeta.icon} +
+
{/* 줄임처리 핵심 */} +

+ {displayDescription} +

+

+ {new Date(createdAt).toLocaleString()} +

+
+
+ +
+ {typeMeta.sign} + {Math.abs(amount).toLocaleString()} TKT +
+
+ + ); +} diff --git a/app/wallet/components/common/TransactionList.tsx b/app/wallet/components/common/TransactionList.tsx new file mode 100644 index 0000000..2dbd3ac --- /dev/null +++ b/app/wallet/components/common/TransactionList.tsx @@ -0,0 +1,60 @@ +import { useRouter } from "next/navigation"; +import { Card, CardContent } from "@/components/ui/card"; +import TransactionCardContent from "./TransactionCardContent" +import { Transaction } from "@/app/wallet/api/fetch-transactions"; + +interface TransactionListProps { + label?: string; + transactions: Transaction[]; + limit?: number; +} + +export default function TransactionList({ + label, + transactions, + limit, + }: TransactionListProps) { + const router = useRouter(); + const data = limit ? transactions.slice(0, limit) : transactions; + + return ( +
+ {label && ( +

{label}

+ )} + {data.length > 0 ? ( + data.map((tx, index) => { + if (typeof tx.id !== "number") { + return null; + } + + const handleClick = () => { + router.push(`/wallet/totaltransaction/${tx.id}`); + }; + + return ( + + + + + + ); + }) + ) : ( +
+ 표시할 거래 내역이 없습니다. +
+ )} +
+ ); +} diff --git a/app/wallet/components/common/WalletCard.tsx b/app/wallet/components/common/WalletCard.tsx new file mode 100644 index 0000000..f3a91e5 --- /dev/null +++ b/app/wallet/components/common/WalletCard.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Wallet, ArrowRight } from "lucide-react"; +import { Coins, Banknote } from "lucide-react"; + +interface WalletCardProps { + tokenBalance: number; + depositBalance: number; + userName: string; + accountNumber: string; +} + +export default function WalletCard({ + tokenBalance, + depositBalance, + userName, + accountNumber +}: WalletCardProps) { + return ( + +
+
+
+ +
+
+
+ +
+
+

+ {userName} 님의 지갑 +

+

{accountNumber}

+
+
+ +
+
+
+
+ +

토큰 잔액

+
+
+
+ {tokenBalance.toLocaleString()} + +
+
+ +
+
+
+ +

예금 잔액

+
+
+
+ {depositBalance.toLocaleString()} + +
+
+
+
+
+ ); +} diff --git a/app/wallet/components/common/WalletGuide.tsx b/app/wallet/components/common/WalletGuide.tsx new file mode 100644 index 0000000..04176dd --- /dev/null +++ b/app/wallet/components/common/WalletGuide.tsx @@ -0,0 +1,23 @@ +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Info, ChevronRight } from "lucide-react"; + +export default function WalletGuide() { + const router = useRouter(); + + return ( +
+ +
+ ); +} diff --git a/app/wallet/components/convert/BalanceCard.tsx b/app/wallet/components/convert/BalanceCard.tsx new file mode 100644 index 0000000..5f3ba58 --- /dev/null +++ b/app/wallet/components/convert/BalanceCard.tsx @@ -0,0 +1,44 @@ +interface BalanceCardProps { + type: "deposit-to-token" | "token-to-deposit"; + depositBalance: number; + tokenBalance: number; +} + +export default function BalanceCard({ + type, + depositBalance, + tokenBalance, +}: BalanceCardProps) { + const isDepositToToken = type === "deposit-to-token"; + + const primaryStyle = "text-base font-semibold text-[#1A1A1A]"; + const secondaryStyle = "text-base text-[#666666]"; + + return ( +
+ {isDepositToToken ? ( + <> +
+

예금 잔액

+

{depositBalance.toLocaleString()}원

+
+
+

토큰 잔액

+

{tokenBalance.toLocaleString()}원

+
+ + ) : ( + <> +
+

토큰 잔액

+

{tokenBalance.toLocaleString()}원

+
+
+

예금 잔액

+

{depositBalance.toLocaleString()}원

+
+ + )} +
+ ); +} diff --git a/app/wallet/components/convert/ConvertPreview.tsx b/app/wallet/components/convert/ConvertPreview.tsx new file mode 100644 index 0000000..4a92142 --- /dev/null +++ b/app/wallet/components/convert/ConvertPreview.tsx @@ -0,0 +1,107 @@ +import Image from "next/image"; + +interface ConvertPreviewProps { + type: "deposit-to-token" | "token-to-deposit"; + amount: string; + depositBalance: number; + tokenBalance: number; +} + +export default function ConvertPreview({ + type, + amount, + depositBalance, + tokenBalance, +}: ConvertPreviewProps) { + const parsedAmount = Number.parseInt(amount); + const isDepositToToken = type === "deposit-to-token"; + + const labelFrom = isDepositToToken ? "예금에서" : "토큰에서"; + const labelTo = isDepositToToken ? "토큰으로" : "예금으로"; + const imageSrc = "/images/arrow-down.gif"; + + // 계산된 전환 후 잔액 + const finalDeposit = isDepositToToken + ? depositBalance - parsedAmount // 예금 → 토큰 시 예금에서 차감 + : depositBalance + parsedAmount; // 토큰 → 예금 시 예금에 더함 + + const finalToken = isDepositToToken + ? tokenBalance + parsedAmount // 예금 → 토큰 시 토큰에 더함 + : tokenBalance - parsedAmount; // 토큰 → 예금 시 토큰에서 차감 + + return ( +
+

+ 전환 정보 확인 +

+ +
+
+

{labelFrom}

+

+ {" "} + -{parsedAmount.toLocaleString()}원 +

+
+ + 전환 이미지 + +
+

{labelTo}

+

+ {" "} + +{parsedAmount.toLocaleString()}원 +

+
+
+ +
+ {isDepositToToken ? ( + <> +
+

전환 후 예금 잔액

+

+ {finalDeposit.toLocaleString()}원 +

+
+
+

전환 후 토큰 잔액

+

+ {finalToken.toLocaleString()}원 +

+
+ + ) : ( + <> +
+

전환 후 토큰 잔액

+

+ {finalToken.toLocaleString()}원 +

+
+
+

전환 후 예금 잔액

+

+ {finalDeposit.toLocaleString()}원 +

+
+ + )} +
+
+ ); +} diff --git a/app/wallet/components/guide/GuideIntroCard.tsx b/app/wallet/components/guide/GuideIntroCard.tsx new file mode 100644 index 0000000..de204ce --- /dev/null +++ b/app/wallet/components/guide/GuideIntroCard.tsx @@ -0,0 +1,21 @@ +import { Wallet } from "lucide-react"; + +export default function WalletIntroCard() { + return ( +
+
+
+ +
+

+ 전자지갑 소개 +

+
+

+ Tokkit 전자지갑은 중앙은행 디지털 화폐(CBDC)를 안전하게 보관하고 사용할 + 수 있는 서비스입니다. 예금을 토큰으로 전환하여 결제, 송금 등 다양한 금융 + 활동을 할 수 있습니다. +

+
+ ); +} diff --git a/app/wallet/components/guide/GuideList.tsx b/app/wallet/components/guide/GuideList.tsx new file mode 100644 index 0000000..bba1627 --- /dev/null +++ b/app/wallet/components/guide/GuideList.tsx @@ -0,0 +1,37 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { guideItems } from "@/data/wallet/guideContents"; + +export default function GuideAccordion() { + return ( + + {guideItems.map(({ value, icon: Icon, title, content }) => ( + + +
+
+ +
+ {title} +
+
+ +
"), + }} + /> + + + ))} + + ); +} diff --git a/app/wallet/components/totalhistory/Calendar.tsx b/app/wallet/components/totalhistory/Calendar.tsx new file mode 100644 index 0000000..d57ff8c --- /dev/null +++ b/app/wallet/components/totalhistory/Calendar.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import { CalendarIcon } from "lucide-react"; +import { Calendar as DatePicker } from "@/components/ui/calendar"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +interface CalendarProps { + selected?: Date; + onSelect: (date: Date | undefined) => void; + onResetFilters?: () => void; +} + +export default function Calendar({ selected, onSelect, onResetFilters }: CalendarProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + { + if (!d) return; + if (selected && d.toDateString() === selected.toDateString()) + return; + onSelect(d); + }} + defaultMonth={new Date()} + initialFocus + classNames={{ + day_today: "border border-[#FFB020]", + day_selected: "bg-[#F0F0F0] text-black", + day_outside: "text-gray-400", + }} + /> + + + + + ); +} diff --git a/app/wallet/components/totalhistory/Category.tsx b/app/wallet/components/totalhistory/Category.tsx new file mode 100644 index 0000000..c7561d6 --- /dev/null +++ b/app/wallet/components/totalhistory/Category.tsx @@ -0,0 +1,41 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface CategoryOption { + label: string; + value: string; +} + +interface CategoryProps { + label: string; + options: CategoryOption[]; + value?: string; + onChange: (value: string) => void; +} + +export default function Category({ + label, + options, + onChange, + value, +}: CategoryProps) { + return ( + + ); +} diff --git a/app/wallet/components/totalhistory/SearchBar.tsx b/app/wallet/components/totalhistory/SearchBar.tsx new file mode 100644 index 0000000..c54e549 --- /dev/null +++ b/app/wallet/components/totalhistory/SearchBar.tsx @@ -0,0 +1,21 @@ +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; + +interface SearchBarProps { + value: string; + onChange: (v: string) => void; +} + +export default function SearchBar({ value, onChange }: SearchBarProps) { + return ( +
+ + onChange(e.target.value)} + className="pl-10 h-10 rounded-lg border-[#E0E0E0] bg-white" + /> +
+ ); +} diff --git a/app/wallet/convert/[type]/page.tsx b/app/wallet/convert/[type]/page.tsx new file mode 100644 index 0000000..286a4a5 --- /dev/null +++ b/app/wallet/convert/[type]/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useEffect, useState } from "react"; +import {useParams, useRouter, useSearchParams} from "next/navigation"; +import Header from "@/components/common/Header"; +import AmountStep from "@/app/wallet/components/AmountStep"; +import ConfirmStep from "@/app/wallet/components/ConfirmStep"; +import ProcessingStep from "@/app/wallet/components/ProcessingStep"; +import CompleteStep from "@/app/wallet/components/CompleteStep"; +import VerifySimplePassword from "@/app/payment/components/VerifySimplePassword"; +import { fetchWalletBalance, verifyPassword, convertBalance } from "@/app/wallet/api/wallet"; + +export default function ConvertPage() { + const router = useRouter(); + const params = useParams(); + const type = params.type as "deposit-to-token" | "token-to-deposit"; + + const [step, setStep] = useState<"amount" | "confirm" | "password" | "processing" | "complete">("amount"); + const [amount, setAmount] = useState(""); + const [depositBalance, setDepositBalance] = useState(0); + const [tokenBalance, setTokenBalance] = useState(0); + + const isDepositToToken = type === "deposit-to-token"; + const title = isDepositToToken ? "예금 → 토큰" : "토큰 → 예금"; + + useEffect(() => { + fetchBalance(); + }, []); + + const fetchBalance = async () => { + try { + const data = await fetchWalletBalance(); + setDepositBalance(data.depositBalance); + setTokenBalance(data.tokenBalance); + } catch (err) { + alert("지갑 정보를 불러오지 못했습니다."); + } + }; + + const handleMax = () => { + const max = isDepositToToken ? depositBalance : tokenBalance; + setAmount(String(max)); + }; + + const handlePasswordComplete = async (password: string) => { + const amountNum = Number(amount); + try { + await verifyPassword(password); + } catch (err: any) { + alert(err.message); + setStep("password"); + return; + } + + setStep("processing"); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + try { + await convertBalance(type, amountNum, password); + await fetchBalance(); + setStep("complete"); + } catch (err: any) { + alert(err.message); + setStep("amount"); + } + }; + + return ( +
+
+
+ {step === "amount" && ( + setStep("confirm")} + /> + )} + + {step === "confirm" && ( + setStep("amount")} + onConfirm={() => setStep("password")} + /> + )} + + {step === "password" && ( +
+ 간편 비밀번호 입력 + +
+ )} + + + {step === "processing" && } + + {step === "complete" && ( + router.push("/wallet")} + /> + )} +
+
+ ); +} diff --git a/app/wallet/guide/page.tsx b/app/wallet/guide/page.tsx new file mode 100644 index 0000000..49ae4b0 --- /dev/null +++ b/app/wallet/guide/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { motion } from "framer-motion"; +import Header from "@/components/common/Header"; +import GuideIntroCard from "@/app/wallet/components/guide/GuideIntroCard"; +import GuideList from "@/app/wallet/components/guide/GuideList"; +import InfoBox from "@/app/wallet/components/common/InfoBox"; + +export default function WalletGuidePage() { + return ( +
+
+ +
+ + + + +

더 자세한 내용은 고객센터(1234-5678)로 문의해주세요.

+

운영 시간: 평일 09:00 ~ 18:00

+
+
+
+
+ ); +} diff --git a/app/wallet/page.tsx b/app/wallet/page.tsx new file mode 100644 index 0000000..9e74f71 --- /dev/null +++ b/app/wallet/page.tsx @@ -0,0 +1,97 @@ +"use client" + +import { useEffect, useState, Suspense } from "react" +import { useSearchParams, useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { ChevronRight } from "lucide-react" +import Header from "@/components/common/Header" +import WalletGuide from "@/app/wallet/components/common/WalletGuide" +import TransactionList from "@/app/wallet/components/common/TransactionList" +import ConvertButton from "@/app/wallet/components/common/ConvertButton" +import WalletCard from "@/app/wallet/components/common/WalletCard" +import { fetchWalletTransactions } from "@/app/wallet/api/wallet" +import { fetchWalletInfo } from "@/app/dashboard/api/wallet-info" + +function WalletContent() { + const router = useRouter() + const searchParams = useSearchParams() + const refresh = searchParams.get("refresh") + + const [userName, setUserName] = useState("") + const [accountNumber, setAccountNumber] = useState("") + const [tokenBalance, setTokenBalance] = useState(null) + const [depositBalance, setDepositBalance] = useState(null) + const [transactions, setTransactions] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetchData = async () => { + try { + const summary = await fetchWalletInfo() + setUserName(summary.name) + setAccountNumber(summary.accountNumber) + setTokenBalance(summary.tokenBalance) + setDepositBalance(summary.depositBalance) + + const txs = await fetchWalletTransactions() + setTransactions(txs) + } catch (error) { + console.error("API 요청 오류:", error) + alert("지갑 데이터를 불러오는 중 오류 발생") + } finally { + setLoading(false) + } + } + + fetchData() + }, [refresh]) + + const recentTransactions = transactions.slice(0, 3) + + return ( +
+
+
+ {tokenBalance !== null && depositBalance !== null && ( + + )} +
+ + +
+ {loading ? ( +

최근 거래를 불러오는 중...

+ ) : ( + + )} +
+ +
+
+ + +
+
+
+ ) +} + +export default function WalletPage() { + return ( + Loading...
}> + + + ) +} diff --git a/app/wallet/totaltransaction/[id]/page.tsx b/app/wallet/totaltransaction/[id]/page.tsx new file mode 100644 index 0000000..b4a94f5 --- /dev/null +++ b/app/wallet/totaltransaction/[id]/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useParams } from "next/navigation"; +import { motion } from "framer-motion"; +import Header from "@/components/common/Header"; +import TransactionCardContent from "@/app/wallet/components/common/TransactionCardContent"; +import {fetchTransactionDetail} from "@/app/wallet/api/fetch-transactions-detail"; +import Link from "next/link"; + + +interface TransactionDetail { + id: number; + type: string; + amount: number; + displayDescription: string; + createdAt: string; + txHash: string; +} + +export default function TransactionDetailPage() { + const params = useParams(); + const id = typeof params?.id === "string" ? params.id : undefined; + + const [transaction, setTransaction] = useState( + null + ); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) { + setLoading(false); + return; + } + + fetchTransactionDetail(id) + .then((data) => { + setTransaction(data); + }) + .catch((error) => { + console.error("거래내역 조회 오류:", error); + }) + .finally(() => { + setLoading(false); + }); + }, [id]); + + + if (loading) { + return ( +
+

거래내역을 불러오는 중...

+
+ ); + } + + if (!transaction) { + return ( +
+

거래내역을 찾을 수 없습니다.

+
+ ); + } + + const isNegative = transaction.amount < 0; + const formattedAmount = `${isNegative ? "" : "+"}${Math.abs( + transaction.amount + ).toLocaleString()}원`; + + const date = new Date(transaction.createdAt); + const formattedDate = `${date.getFullYear()}년 ${ + date.getMonth() + 1 + }월 ${date.getDate()}일`; + + const hours = date.getHours(); + const ampm = hours < 12 ? "오전" : "오후"; + const displayHours = hours % 12 || 12; + const formattedTime = `${ampm} ${displayHours}:${String( + date.getMinutes() + ).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`; + + const getKoreanType = (type: string) => { + switch (type) { + case "PAYMENT": + return "결제"; + case "CONVERT": + return "변환"; + case "PURCHASE": + return "구매"; + default: + return type; + } + }; + + return ( +
+
+ + + + + + +

거래 정보

+ +
+
거래 유형
+
{getKoreanType(transaction.type)}
+
+ +
+
거래 설명
+ + {transaction.txHash.slice(0, 10)}...{transaction.txHash.slice(-6)} 🔗 + +
+
+ +
+ + +

시간 정보

+ +
+
거래 일자
+
{formattedDate}
+
+ +
+
거래 시간
+
{formattedTime}
+
+
+
+ ); +} diff --git a/app/wallet/totaltransaction/loading/skeleton.tsx b/app/wallet/totaltransaction/loading/skeleton.tsx new file mode 100644 index 0000000..ccd47e7 --- /dev/null +++ b/app/wallet/totaltransaction/loading/skeleton.tsx @@ -0,0 +1,111 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { ArrowLeft, Search, Calendar } from "lucide-react" + +export const SkeletonLoader = () => { + return ( +
+
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+
+
+ ) +} + +const TransactionItemSkeleton = () => { + return ( +
+
+
+ {/* Icon Skeleton */} + + +
+ {/* Transaction Description */} + + {/* Transaction Date */} + +
+
+ +
+ {/* Amount */} + + {/* Status */} + +
+
+
+ ) +} + + +export const SearchBarSkeleton = () => ( +
+
+ + +
+
+ + +
+
+) + +export const CategoryFilterSkeleton = () => ( +
+ + +
+) + +export const TransactionListSkeleton = () => ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+) diff --git a/app/wallet/totaltransaction/page.tsx b/app/wallet/totaltransaction/page.tsx new file mode 100644 index 0000000..c584b96 --- /dev/null +++ b/app/wallet/totaltransaction/page.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { fetchWalletInfo } from "@/app/dashboard/api/wallet-info"; +import { RotateCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import Header from "@/components/common/Header"; +import SearchBar from "@/app/wallet/components/totalhistory/SearchBar"; +import Category from "@/app/wallet/components/totalhistory/Category"; +import Calendar from "@/components/common/Calendar"; +import TransactionList from "@/app/wallet/components/common/TransactionList"; +import { + fetchTransactions, + type Transaction, +} from "@/app/wallet/api/fetch-transactions"; +import { SkeletonLoader } from "@/app/wallet/totaltransaction/loading/skeleton"; + +interface WalletInfo { + userId: number; + name: string; + accountNumber: string; + tokenBalance: number; +} + +export default function TransactionsPage() { + const [walletInfo, setWalletInfo] = useState(null); + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + + const [searchTerm, setSearchTerm] = useState(""); + const [date, setDate] = useState<{ from?: Date; to?: Date } | undefined>( + undefined + ); + + const [type, setType] = useState("전체"); + const [period, setPeriod] = useState("전체 기간"); + + const handleResetFilters = () => { + setType("전체"); + setPeriod("전체 기간"); + }; + + const typeOptions = [ + { label: "전체", value: "전체" }, + { label: "결제", value: "결제" }, + { label: "변환", value: "변환" }, + ]; + + const periodOptions = [ + { label: "전체 기간", value: "전체 기간" }, + { label: "최근 1주일", value: "week" }, + { label: "최근 1개월", value: "month" }, + ]; + + useEffect(() => { + const fetchData = async () => { + try { + const wallet = await fetchWalletInfo(); + setWalletInfo(wallet); + } catch (error) { + console.error("지갑 정보 로드 실패:", error); + } + }; + + fetchData(); + }, []); + + useEffect(() => { + fetchTransactions() + .then((data) => { + setTransactions(data); + }) + .catch((error) => { + console.error("거래내역 조회 오류:", error); + alert("거래내역 불러오기 중 오류 발생"); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + const filteredTransactions = transactions.filter((tx: any) => { + if ( + searchTerm && + !tx.displayDescription?.toLowerCase().includes(searchTerm.toLowerCase()) + ) { + return false; + } + + if (type !== "전체") { + const typeMap: Record = { + 결제: "PURCHASE", + 변환: "CONVERT", + }; + if (tx.type !== typeMap[type]) return false; + } + + if (period !== "전체 기간") { + const txDate = new Date(tx.createdAt); + const today = new Date(); + const diff = today.getTime() - txDate.getTime(); + + if (period === "week" && diff > 7 * 86400000) return false; + if (period === "month" && diff > 30 * 86400000) return false; + } + + if (date?.from) { + const txDate = new Date(tx.createdAt); + const from = new Date(date.from); + const to = date.to ? new Date(date.to) : from; + + txDate.setHours(0, 0, 0, 0); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + + if (txDate < from || txDate > to) return false; + } + + return true; + }); + + if (loading) { + return ; + } + + return ( +
+
+ +
+
+ + +
+ +
+
+ {date?.from + ? `${date.from.toLocaleDateString()} ${ + date.to ? `~ ${date.to.toLocaleDateString()}` : "" + }` + : "날짜를 선택해주세요"} +
+ +
+ +
+ + + +
+
+ +
+ +
+
+ ); +} diff --git a/app/wallet/voucher/purchase/page.tsx b/app/wallet/voucher/purchase/page.tsx new file mode 100644 index 0000000..6429865 --- /dev/null +++ b/app/wallet/voucher/purchase/page.tsx @@ -0,0 +1,63 @@ +"use client" + +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { CheckIcon } from "lucide-react" + +export default function VoucherPurchasePage() { + const router = useRouter() + + return ( +
+ {/* Success animation */} +
+ {/* Circle background */} + + {/* Inner circle */} + + {/* Check mark */} + + + + + +
+ + +

바우처 구매 성공!

+

구매하신 바우처는 내 바우처 목록에서 확인하실 수 있습니다.

+
+ + router.push("/my-vouchers")} + initial={{ y: 20, opacity: 0 }} + animate={{ y: 0, opacity: 1 }} + transition={{ delay: 1.5, duration: 0.5 }} + whileHover={{ scale: 1.03 }} + whileTap={{ scale: 0.97 }} + > + 내 바우처 확인하기 + +
+ ) +} diff --git a/components/auth-buttons.tsx b/components/auth-buttons.tsx new file mode 100644 index 0000000..0644c1a --- /dev/null +++ b/components/auth-buttons.tsx @@ -0,0 +1,43 @@ +"use client" + +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" + +interface AuthButtonsProps { + onLogin: () => void + onSignup: () => void + loginText?: string + signupText?: string + isAnimating?: boolean +} + +export function AuthButtons({ + onLogin, + onSignup, + loginText = "로그인", + signupText = "회원가입", + isAnimating = false, +}: AuthButtonsProps) { + return ( + + + + + ) +} diff --git a/components/back-button.tsx b/components/back-button.tsx new file mode 100644 index 0000000..e425730 --- /dev/null +++ b/components/back-button.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" + +interface BackButtonProps { + href?: string +} + +export function BackButton({ href = "/" }: BackButtonProps) { + const router = useRouter() + + return ( + + ) +} diff --git a/components/common/AmountInput.tsx b/components/common/AmountInput.tsx new file mode 100644 index 0000000..f30b41f --- /dev/null +++ b/components/common/AmountInput.tsx @@ -0,0 +1,69 @@ +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +interface AmountInputProps { + amount: string; + onChange: (value: string) => void; + onMax: () => void; + label?: string; + bottomRightText?: React.ReactNode; +} + +export default function AmountInput({ + amount, + onChange, + onMax, + label = "전환할 금액", + bottomRightText, +}: AmountInputProps) { + const handleInputChange = (e: React.ChangeEvent) => { + const raw = e.target.value.replace(/[^0-9]/g, ""); + onChange(raw); + }; + + return ( +
+
+ + + {bottomRightText && ( +
+ {bottomRightText} +
+ )} +
+
+ + + 원 + +
+ +
+
+ + +
+
+ ); +} diff --git a/components/common/Calendar.tsx b/components/common/Calendar.tsx new file mode 100644 index 0000000..10436b6 --- /dev/null +++ b/components/common/Calendar.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState } from "react"; +import { CalendarIcon } from "lucide-react"; +import { Calendar as DatePicker } from "@/components/ui/calendar"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { DateRange } from "react-day-picker"; +import { isAfter } from "date-fns"; + +interface CalendarProps { + selected?: { from?: Date; to?: Date }; + onSelect: (range: { from?: Date; to?: Date } | undefined) => void; + onResetFilters?: () => void; +} + +export default function Calendar({ + selected, + onSelect, + onResetFilters, +}: CalendarProps) { + const [open, setOpen] = useState(false); + const [range, setRange] = useState(undefined); + + // 비활성화할 날짜 조건 설정 + const getDisabledDates = () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // 시작일이 선택된 경우와 아닌 경우를 분리 + if (range?.from) { + return [ + { after: today }, // 오늘 이후 날짜 비활성화 + { before: range.from }, // 시작일 이전 날짜 비활성화 + ]; + } else { + return { after: today }; // 오늘 이후 날짜만 비활성화 + } + }; + + return ( + { + setOpen(o); + if (o) { + setRange(undefined); + } + }} + > + + + + + { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // 오늘 이후 날짜는 선택 불가 + if (r?.from && isAfter(r.from, today)) return; + if (r?.to && isAfter(r.to, today)) return; + + setRange(r); + + // 범위가 완성되면 확정하고 캘린더 닫기 + if (r?.from && r?.to) { + onSelect(r); + setOpen(false); + } + }} + numberOfMonths={1} + disabled={getDisabledDates()} + classNames={{ + day_today: "border border-[#FFB020]", + day_selected: "bg-[#F0F0F0] text-black", + day_outside: "text-gray-400", + day_range_start: "bg-[#FFB020] text-white rounded-l-md font-bold", + day_range_end: "bg-[#FFB020] text-white rounded-r-md font-bold", + day_range_middle: "bg-[#FFE4A1] text-black", + day_disabled: "text-gray-300 cursor-not-allowed", + }} + /> + + + + + ); +} diff --git a/components/common/Header.tsx b/components/common/Header.tsx new file mode 100644 index 0000000..b56626d --- /dev/null +++ b/components/common/Header.tsx @@ -0,0 +1,29 @@ +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; + +interface HeaderProps { + title: string; + backHref?: string; + onBack?: () => void; +} + +export default function Header({ title, backHref, onBack }: HeaderProps) { + const handleBack = () => { + if (onBack) { + onBack(); + } else if (backHref) { + window.location.href = backHref; + } else { + window.history.back(); + } + }; + + return ( +
+ +

{title}

+
+ ); +} \ No newline at end of file diff --git a/components/common/LoadingOverlay.tsx b/components/common/LoadingOverlay.tsx new file mode 100644 index 0000000..9e0d3cd --- /dev/null +++ b/components/common/LoadingOverlay.tsx @@ -0,0 +1,44 @@ +"use client" + +import { motion } from "framer-motion" + +interface LoadingOverlayProps { + message?: string +} + +export default function LoadingOverlay({ message = "처리 중입니다. 잠시만 기다려주세요..." }: LoadingOverlayProps) { + return ( +
+ + + + + + + {message} + + +
+ ) +} diff --git a/components/common/NotificationToast.tsx b/components/common/NotificationToast.tsx new file mode 100644 index 0000000..d55a3dc --- /dev/null +++ b/components/common/NotificationToast.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useEffect, useState } from "react" +import { Bell, X } from "lucide-react" // lucide 아이콘 + +interface Props { + title: string + content: string + visible: boolean +} + +export default function NotificationToast({ title, content, visible }: Props) { + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + if (visible) { + setIsVisible(true) + const timer = setTimeout(() => { + setIsVisible(false) + }, 4000) + return () => clearTimeout(timer) + } + }, [visible, title, content]) + + useEffect(() => { + if (!visible) setIsVisible(false) + }, [visible]) + + if (!isVisible) return null + + return ( +
+
+
+ +
+
+
{title}
+
{content}
+
+ +
+
+ ) +} diff --git a/components/common/Pagination.tsx b/components/common/Pagination.tsx new file mode 100644 index 0000000..5c83084 --- /dev/null +++ b/components/common/Pagination.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; + +interface PaginationProps { + totalPages: number; + currentPage: number; + onPageChange: (page: number) => void; +} + +export default function Pagination({ + totalPages, + currentPage, + onPageChange, +}: PaginationProps) { + const blockSize = 5; + const currentBlock = Math.floor((currentPage - 1) / blockSize); + const startPage = currentBlock * blockSize + 1; + const endPage = Math.min(startPage + blockSize - 1, totalPages); + + const pageNumbers = []; + for (let i = startPage; i <= endPage; i++) { + pageNumbers.push(i); + } + return ( +
+ + + + {pageNumbers.map((page) => ( + + ))} + + + +
+ ); +} diff --git a/components/common/SimplePassWord.tsx b/components/common/SimplePassWord.tsx new file mode 100644 index 0000000..a2e699d --- /dev/null +++ b/components/common/SimplePassWord.tsx @@ -0,0 +1,32 @@ +import { motion } from "framer-motion"; +import VirtualKeypad from "@/components/virtual-keypad"; + +interface SimplePassWordProps { + onComplete: (password: string) => void; + isConfirm?: boolean; +} + +export default function PasswordStep({ + onComplete, + isConfirm = false, +}: SimplePassWordProps) { + const title = isConfirm ? "간편 비밀번호 재입력" : "간편 비밀번호 입력"; + const subtitle = isConfirm + ? "다시 한 번 입력해주세요" + : "전환을 완료하려면 비밀번호를 입력하세요"; + + return ( + +
+

{title}

+

{subtitle}

+
+ + +
+ ); +} diff --git a/components/common/TransactionCardContent.tsx b/components/common/TransactionCardContent.tsx new file mode 100644 index 0000000..bff19c0 --- /dev/null +++ b/components/common/TransactionCardContent.tsx @@ -0,0 +1,60 @@ +import { ArrowDownUp } from "lucide-react"; + +interface Props { + displayDescription: string; + amount: number; + createdAt: string; + disableClick?: boolean; +} + +export default function TransactionCardContent({ + displayDescription, + amount, + createdAt, + }: Props) { + const safeDescription = displayDescription ?? "거래 상세 없음"; + + const isDepositToToken = + safeDescription.includes("예금") && + safeDescription.includes("토큰") && + safeDescription.indexOf("예금") < safeDescription.indexOf("토큰"); + + const isTokenToDeposit = + safeDescription.includes("토큰") && + safeDescription.includes("예금") && + safeDescription.indexOf("토큰") < safeDescription.indexOf("예금"); + + const colorClass = isDepositToToken + ? "text-green-500" + : isTokenToDeposit + ? "text-blue-500" + : "text-gray-500"; + + const bgColorClass = isDepositToToken + ? "bg-green-100" + : isTokenToDeposit + ? "bg-blue-100" + : "bg-gray-100"; + + return ( +
+
+
+ +
+
+

{displayDescription}

+

+ {new Date(createdAt).toLocaleString()} +

+
+
+
+ {amount >= 0 ? "+" : ""} + {amount.toLocaleString()} TKT +
+
+ ); +} diff --git a/components/common/TransactionList.tsx b/components/common/TransactionList.tsx new file mode 100644 index 0000000..adcde76 --- /dev/null +++ b/components/common/TransactionList.tsx @@ -0,0 +1,65 @@ +import { useRouter } from "next/navigation"; +import { Card, CardContent } from "@/components/ui/card"; +import TransactionCardContent from "@/components/common/TransactionCardContent"; + +interface Transaction { + id?: number; + type: string; + amount: number; + displayDescription: string; + createdAt: string; +} + +interface TransactionListProps { + label?: string; + transactions: Transaction[]; + limit?: number; +} + +export default function TransactionList({ + label, + transactions, + limit, +}: TransactionListProps) { + const router = useRouter(); + const data = limit ? transactions.slice(0, limit) : transactions; + + return ( +
+ {label && ( +

{label}

+ )} + {data.length > 0 ? ( + data.map((tx, index) => { + if (typeof tx.id !== "number") { + return null; + } + + const handleClick = () => { + router.push(`/wallet/totaltransaction/${tx.id}`); + }; + + return ( + + + + + + ); + }) + ) : ( +
+ 표시할 거래 내역이 없습니다. +
+ )} +
+ ); +} diff --git a/components/common/WithAuth.tsx b/components/common/WithAuth.tsx new file mode 100644 index 0000000..c034b1e --- /dev/null +++ b/components/common/WithAuth.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function withAuth

(WrappedComponent: React.ComponentType

) { + return function ProtectedComponent(props: P) { + const router = useRouter(); + + useEffect(() => { + const accessToken = getCookie("accessToken"); + + // 토큰 없으면 로그인으로 + if (!accessToken) { + router.replace("/login"); + } + }, []); + + return ; + }; +} + +function getCookie(name: string): string | null { + if (typeof document === "undefined") return null; + const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)")); + return match ? match[2] : null; +} diff --git a/components/error-screen.tsx b/components/error-screen.tsx new file mode 100644 index 0000000..008c433 --- /dev/null +++ b/components/error-screen.tsx @@ -0,0 +1,59 @@ +"use client" + +import { motion } from "framer-motion" +import { AlertTriangle } from "lucide-react" +import { Button } from "@/components/ui/button" + +interface ErrorScreenProps { + title?: string + message?: string + onRetry?: () => void +} + +export default function ErrorScreen({ + title = "오류가 발생했습니다", + message = "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", + onRetry, +}: ErrorScreenProps) { + return ( +

+ + + + + + {title} + + + + {message} + + + {onRetry && ( + + + + )} +
+ ) +} diff --git a/components/loading-screen.tsx b/components/loading-screen.tsx new file mode 100644 index 0000000..5342094 --- /dev/null +++ b/components/loading-screen.tsx @@ -0,0 +1,35 @@ +"use client" + +import { motion } from "framer-motion" + +interface LoadingScreenProps { + message?: string +} + +export default function LoadingScreen({ message = "로딩 중..." }: LoadingScreenProps) { + return ( +
+ + + + + + {message} + +
+ ) +} diff --git a/components/merchant-login-from.tsx b/components/merchant-login-from.tsx new file mode 100644 index 0000000..7567218 --- /dev/null +++ b/components/merchant-login-from.tsx @@ -0,0 +1,90 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { Eye, EyeOff, Store } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" + +interface MerchantLoginFormProps { + onBackToCustomer: () => void +} + +export default function MerchantLoginForm({ onBackToCustomer }: MerchantLoginFormProps) { + const [showPassword, setShowPassword] = useState(false) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + // 실제 로그인 로직 구현 (여기서는 시뮬레이션) + await new Promise((resolve) => setTimeout(resolve, 1000)) + setLoading(false) + } + + return ( +
+
+ + 가맹점주 로그인 +
+ +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ + +
+
+ ) +} diff --git a/components/merchant-signup-from.tsx b/components/merchant-signup-from.tsx new file mode 100644 index 0000000..5e515e2 --- /dev/null +++ b/components/merchant-signup-from.tsx @@ -0,0 +1,151 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { Eye, EyeOff, Store } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" + +interface MerchantSignupFormProps { + onBackToCustomer: () => void +} + +export default function MerchantSignupForm({ onBackToCustomer }: MerchantSignupFormProps) { + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + // 실제 회원가입 로직 구현 (여기서는 시뮬레이션) + await new Promise((resolve) => setTimeout(resolve, 1000)) + setLoading(false) + } + + return ( +
+
+ + 가맹점주 회원가입 +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+

8자 이상, 영문, 숫자, 특수문자 포함

+
+ +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ ) +} diff --git a/components/page-header.tsx b/components/page-header.tsx new file mode 100644 index 0000000..d452205 --- /dev/null +++ b/components/page-header.tsx @@ -0,0 +1,37 @@ +"use client" + +import { motion } from "framer-motion" +import {ReactNode} from "react"; + +interface PageHeaderProps { + title: string + description?: ReactNode + isAnimating?: boolean + className?: string +} + +export function PageHeader({ title, description, isAnimating = false }: PageHeaderProps) { + return ( + <> + + {title} + + + {description && ( + + {description} + + )} + + ) +} diff --git a/components/phone-verification.tsx b/components/phone-verification.tsx new file mode 100644 index 0000000..2b2241d --- /dev/null +++ b/components/phone-verification.tsx @@ -0,0 +1,129 @@ +"use client" + +import { useState } from "react" +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +interface PhoneVerificationProps { + onVerified: () => void +} + +export default function PhoneVerification({ onVerified }: PhoneVerificationProps) { + const [phoneNumber, setPhoneNumber] = useState("") + const [verificationCode, setVerificationCode] = useState("") + const [isCodeSent, setIsCodeSent] = useState(false) + const [timeLeft, setTimeLeft] = useState(180) // 3분 + const [loading, setLoading] = useState(false) + + const handleSendCode = async () => { + if (!phoneNumber || phoneNumber.length < 10) return + + setLoading(true) + // 실제로는 API 호출하여 인증번호 발송 + await new Promise((resolve) => setTimeout(resolve, 1000)) + setLoading(false) + setIsCodeSent(true) + + // 타이머 시작 + const timer = setInterval(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + } + + const handleVerifyCode = async () => { + if (!verificationCode || verificationCode.length !== 6) return + + setLoading(true) + // 실제로는 API 호출하여 인증번호 검증 + await new Promise((resolve) => setTimeout(resolve, 1000)) + setLoading(false) + + // 인증 성공 시 다음 단계로 + onVerified() + } + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs < 10 ? "0" : ""}${secs}` + } + + const formatPhoneNumber = (value: string) => { + const numbers = value.replace(/\D/g, "") + if (numbers.length <= 3) return numbers + if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}` + return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}` + } + + return ( +
+
+ +
+ setPhoneNumber(e.target.value.replace(/\D/g, ""))} + placeholder="휴대폰 번호를 입력하세요" + className="h-12 rounded-xl border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] focus-visible:ring-[#FFD485] dark:focus-visible:ring-[#FFB020] focus-visible:ring-offset-0" + maxLength={13} + disabled={isCodeSent && timeLeft > 0} + /> + +
+
+ + {isCodeSent && ( + +
+ + 60 ? "text-[#666666] dark:text-[#BBBBBB]" : "text-red-500"}`}> + {formatTime(timeLeft)} + +
+
+ setVerificationCode(e.target.value.replace(/\D/g, ""))} + placeholder="인증번호 6자리를 입력하세요" + className="h-12 rounded-xl border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] focus-visible:ring-[#FFD485] dark:focus-visible:ring-[#FFB020] focus-visible:ring-offset-0" + maxLength={6} + disabled={timeLeft === 0 || loading} + /> + +
+ {timeLeft === 0 && ( +

인증 시간이 만료되었습니다. 인증번호를 다시 받아주세요.

+ )} +
+ )} +
+ ) +} diff --git a/components/pull-tab.tsx b/components/pull-tab.tsx new file mode 100644 index 0000000..29358ad --- /dev/null +++ b/components/pull-tab.tsx @@ -0,0 +1,89 @@ +"use client" + +import type React from "react" + +import { useEffect } from "react" +import { motion, useAnimation, type PanInfo } from "framer-motion" +import { ChevronLeft } from "lucide-react" + +interface PullTabProps { + children: React.ReactNode + tabState: "closed" | "peek" | "open" + setTabState: (state: "closed" | "peek" | "open") => void +} + +export default function PullTab({ children, tabState, setTabState }: PullTabProps) { + const controls = useAnimation() + + // Tab width values + const closedPosition = 0 + const peekPosition = 60 + const openPosition = 350 + + useEffect(() => { + switch (tabState) { + case "closed": + controls.start({ x: closedPosition }) + break + case "peek": + controls.start({ x: peekPosition }) + break + case "open": + controls.start({ x: openPosition }) + break + } + }, [tabState, controls]) + + const handleDragEnd = (event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => { + const { offset, velocity } = info + + // Determine which state to snap to based on drag velocity and position + if (velocity.x < -300) { + // Fast drag to the left - open + setTabState("open") + } else if (velocity.x > 300) { + // Fast drag to the right - close + setTabState("closed") + } else { + // Slower drag - determine based on position + const currentPosition = offset.x + + if (currentPosition < peekPosition / 2) { + setTabState("closed") + } else if (currentPosition < (peekPosition + openPosition) / 2) { + setTabState("peek") + } else { + setTabState("open") + } + } + } + + return ( + + {/* Tab handle */} +
+
setTabState(tabState === "closed" ? "peek" : tabState === "peek" ? "open" : "closed")} + > + +
+
+ + {/* Content */} +
{children}
+
+ ) +} diff --git a/components/qr-code.tsx b/components/qr-code.tsx new file mode 100644 index 0000000..8b2b24f --- /dev/null +++ b/components/qr-code.tsx @@ -0,0 +1,47 @@ +"use client" + +import { useEffect, useRef } from "react" +import QRCodeLib from "qrcode" + +interface QRCodeProps { + value: string + size?: number + level?: "L" | "M" | "Q" | "H" + includeMargin?: boolean + color?: string + backgroundColor?: string +} + +export default function QRCode({ + value, + size = 200, + level = "M", + includeMargin = true, + color = "#000000", + backgroundColor = "#ffffff", +}: QRCodeProps) { + const canvasRef = useRef(null) + + useEffect(() => { + if (canvasRef.current) { + QRCodeLib.toCanvas( + canvasRef.current, + value, + { + width: size, + margin: includeMargin ? 4 : 0, + errorCorrectionLevel: level, + color: { + dark: color, + light: backgroundColor, + }, + }, + (error) => { + if (error) console.error("QR 코드 생성 오류:", error) + }, + ) + } + }, [value, size, level, includeMargin, color, backgroundColor]) + + return +} diff --git a/components/qr-scanner.tsx b/components/qr-scanner.tsx new file mode 100644 index 0000000..5dc661b --- /dev/null +++ b/components/qr-scanner.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Html5Qrcode } from "html5-qrcode"; + +interface QRScannerProps { + onScan: (data: string) => void; + fps?: number; + qrbox?: number; + aspectRatio?: number; +} + +export default function QRScanner({ + onScan, + fps = 10, + qrbox = 250, + aspectRatio = 1.0, +}: QRScannerProps) { + const [isScanning, setIsScanning] = useState(false); + const scannerRef = useRef(null); + const hasStartedRef = useRef(false); + + useEffect(() => { + const scannerId = `qr-scanner-${Math.random() + .toString(36) + .substring(2, 9)}`; + + const container = document.createElement("div"); + container.id = scannerId; + container.style.width = "100%"; + container.style.height = "100%"; + + const parentElement = document.getElementById("scanner-wrapper"); + if (parentElement) { + parentElement.appendChild(container); + } else { + console.error("스캐너 래퍼 요소를 찾을 수 없습니다."); + return; + } + + try { + scannerRef.current = new Html5Qrcode(scannerId); + + const startScanning = async () => { + try { + if (scannerRef.current) { + await scannerRef.current.start( + { facingMode: "environment" }, + { + fps, + qrbox: { width: qrbox, height: qrbox }, + aspectRatio, + }, + (decodedText) => { + onScan(decodedText); + try { + if (scannerRef.current && hasStartedRef.current) { + scannerRef.current.stop().catch(() => {}); + } + } catch (e) {} + setIsScanning(false); + hasStartedRef.current = false; + }, + () => {} + ); + setIsScanning(true); + hasStartedRef.current = true; + } + } catch (err) { + console.error("QR 스캐너 시작 오류:", err); + setIsScanning(false); + hasStartedRef.current = false; + } + }; + + startScanning(); + } catch (err) { + console.error("QR 스캐너 초기화 오류:", err); + } + + return () => { + try { + const video = document.querySelector("video"); + if (video && typeof video.pause === "function") { + video.pause(); + } + + if (scannerRef.current && hasStartedRef.current) { + scannerRef.current + .stop() + .catch(() => {}) + .finally(() => { + scannerRef.current = null; + hasStartedRef.current = false; + }); + } + + if (parentElement && parentElement.contains(container)) { + parentElement.removeChild(container); + } + } catch (e) { + console.error("스캐너 정리 중 오류 발생 (무시됨):", e); + } + }; + }, [fps, qrbox, aspectRatio, onScan]); + + return
; +} diff --git a/components/signup-form.tsx b/components/signup-form.tsx new file mode 100644 index 0000000..453c02b --- /dev/null +++ b/components/signup-form.tsx @@ -0,0 +1,169 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { Eye, EyeOff } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" + +interface SignupFormProps { + onComplete?: () => void +} + +export function SignupForm({ onComplete }: SignupFormProps) { + const [email, setEmail] = useState("") + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [termsAgreed, setTermsAgreed] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + // 간단한 유효성 검사 + if (password !== confirmPassword) { + setError("비밀번호가 일치하지 않습니다.") + return + } + + if (!termsAgreed) { + setError("이용약관에 동의해주세요.") + return + } + + setLoading(true) + + // 실제로는 API 호출하여 회원가입 처리 + try { + // 회원가입 API 호출 시뮬레이션 + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // 회원가입 성공 시 콜백 호출 + if (onComplete) { + onComplete() + } + } catch (err) { + setError("회원가입 중 오류가 발생했습니다. 다시 시도해주세요.") + } finally { + setLoading(false) + } + } + + return ( +
+
+ + setUsername(e.target.value)} + placeholder="아이디를 입력하세요" + className="h-12 rounded-xl border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] focus-visible:ring-[#FFD485] dark:focus-visible:ring-[#FFB020] focus-visible:ring-offset-0" + required + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="이메일을 입력하세요" + className="h-12 rounded-xl border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] focus-visible:ring-[#FFD485] dark:focus-visible:ring-[#FFB020] focus-visible:ring-offset-0" + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="비밀번호를 입력하세요" + className="h-12 rounded-xl border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] focus-visible:ring-[#FFD485] dark:focus-visible:ring-[#FFB020] focus-visible:ring-offset-0 pr-10" + required + /> + +
+

8자 이상, 영문, 숫자, 특수문자 포함

+
+ +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="비밀번호를 다시 입력하세요" + className="h-12 rounded-xl border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] focus-visible:ring-[#FFD485] dark:focus-visible:ring-[#FFB020] focus-visible:ring-offset-0 pr-10" + required + /> + +
+
+ +
+
+ setTermsAgreed(checked === true)} + className="border-[#CCCCCC] dark:border-[#444444] data-[state=checked]:bg-[#FFB020] dark:data-[state=checked]:bg-[#FFD485] data-[state=checked]:border-[#FFB020] dark:data-[state=checked]:border-[#FFD485]" + /> + +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+ ) +} diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..55c2f6e --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..24c788c --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..25e7b47 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..d6a5226 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>