diff --git a/components/common/ChargeDrawer.tsx b/components/common/ChargeDrawer.tsx new file mode 100644 index 0000000..b6d8bd1 --- /dev/null +++ b/components/common/ChargeDrawer.tsx @@ -0,0 +1,65 @@ +"use client"; + +import React from "react"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { cn } from "@/lib/utils"; +import ShopContent from "@/components/common/charge/ShopContent"; +import ChargeHistoryContent from "@/components/common/charge/ChargeHistoryContent"; +import DepositorNameContent from "@/components/common/charge/DepositorNameContent"; +import ChargeTabs from "@/components/common/charge/ChargeTabs"; +import { TABS } from "@/lib/constants/charge"; + +interface ChargeDrawerProps { + trigger: React.ReactNode; +} + +/* ── 탭별 콘텐츠 ── */ +const TAB_CONTENT = [ShopContent, ChargeHistoryContent, DepositorNameContent]; + +/* ────────────────────────────────────── */ + +export default function ChargeDrawer({ trigger }: ChargeDrawerProps) { + const [activeTab, setActiveTab] = React.useState(0); + + const ActiveContent = TAB_CONTENT[activeTab]; + + return ( + + {trigger} + +
+ {/* ── Header ── */} + +
+
+ + {TABS[activeTab].title} + + + 닫기 + +
+ + + {/* ── 탭 칩 ── */} + + + {/* ── Scrollable Content ── */} +
+ +
+
+ + + ); +} diff --git a/components/common/MyCoinSection.tsx b/components/common/MyCoinSection.tsx index b9265d7..17909e6 100644 --- a/components/common/MyCoinSection.tsx +++ b/components/common/MyCoinSection.tsx @@ -2,6 +2,7 @@ import { useItems } from "@/hooks/useItems"; import { cn } from "@/lib/utils"; import Image from "next/image"; import React from "react"; +import ChargeDrawer from "./ChargeDrawer"; interface MyCoinSectionProps { className?: string; @@ -68,9 +69,13 @@ const MyCoinSection = ({ className }: MyCoinSectionProps) => { {optionTicketCount}개
- + + 충전하기 + + } + /> ); }; diff --git a/components/common/charge/ChargeHistoryContent.tsx b/components/common/charge/ChargeHistoryContent.tsx new file mode 100644 index 0000000..821fb89 --- /dev/null +++ b/components/common/charge/ChargeHistoryContent.tsx @@ -0,0 +1,47 @@ +"use client"; + +import React from "react"; +import { useItems } from "@/hooks/useItems"; +import ChargeInventoryCard from "./ChargeInventoryCard"; +import ChargeHistoryListItem from "./ChargeHistoryListItem"; +import { + BUSINESS_INFO, + NOTICE_INFO, + CHARGE_HISTORY, +} from "@/lib/constants/charge"; + +export default function ChargeHistoryContent() { + const { data } = useItems(); + + const ticketCounts = { + matching: data?.data.matchingTicketCount ?? 0, + option: data?.data.optionTicketCount ?? 0, + }; + + return ( +
+ {/* ── 보유현황 카드 ── */} + + + {/* ── 충전 내역 리스트 ── */} +
+ {CHARGE_HISTORY.map((item) => ( + + ))} +
+ + {/* ── 유의사항 ── */} +
+ 유의사항 +
+

+ {NOTICE_INFO} +

+

+ {BUSINESS_INFO} +

+
+
+
+ ); +} diff --git a/components/common/charge/ChargeHistoryListItem.tsx b/components/common/charge/ChargeHistoryListItem.tsx new file mode 100644 index 0000000..ef5b1bc --- /dev/null +++ b/components/common/charge/ChargeHistoryListItem.tsx @@ -0,0 +1,63 @@ +"use client"; + +import React from "react"; +import { cn } from "@/lib/utils"; + +export type ChargeStatus = "success" | "failed" | "cancelled" | "event"; + +export interface ChargeHistoryItemProps { + date: string; + orderId: string; + points: string; + paymentAmount: string; + status: ChargeStatus; + statusLabel: string; +} + +/* ── 상태별 스타일 헬퍼 ── */ +function getStatusStyles(status: ChargeStatus) { + const isInactive = status === "failed" || status === "cancelled"; + return { + dateColor: isInactive ? "text-color-gray-400" : "text-color-text-black", + pointsColor: isInactive ? "text-color-gray-400" : "text-color-text-black", + amountColor: isInactive ? "text-color-gray-400" : "text-color-text-black", + amountStrike: isInactive, + }; +} + +export default function ChargeHistoryListItem({ + date, + orderId, + points, + paymentAmount, + status, + statusLabel, +}: ChargeHistoryItemProps) { + const styles = getStatusStyles(status); + + return ( +
+ {/* 날짜 | 주문번호 */} + + {date} | {orderId} + + + {/* 포인트 · 결제금액 */} +
+ {points} + + {paymentAmount} + +
+ + {/* 상태 */} + {statusLabel} +
+ ); +} diff --git a/components/common/charge/ChargeInventoryCard.tsx b/components/common/charge/ChargeInventoryCard.tsx new file mode 100644 index 0000000..3597472 --- /dev/null +++ b/components/common/charge/ChargeInventoryCard.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import { + ICON_SIZE, + INVENTORY_ROWS, + PURCHASE_LIMITS, +} from "@/lib/constants/charge"; + +interface ChargeInventoryCardProps { + ticketCounts: { + matching: number; + option: number; + }; +} + +export default function ChargeInventoryCard({ + ticketCounts, +}: ChargeInventoryCardProps) { + return ( +
+ {/* 보유 수량 */} +
+ {INVENTORY_ROWS.map((row) => ( +
+ {row.alt} +
+ + {row.label} + + + {ticketCounts[row.key as keyof typeof ticketCounts]}개 + +
+
+ ))} +
+ + {/* 구매 가능 한도 */} +
+ 구매 가능 한도 +
+ {Object.values(PURCHASE_LIMITS).map((limit) => ( + + {limit.label} {limit.current}/{limit.max} + + ))} +
+
+
+ ); +} diff --git a/components/common/charge/ChargeTabs.tsx b/components/common/charge/ChargeTabs.tsx new file mode 100644 index 0000000..a6436b8 --- /dev/null +++ b/components/common/charge/ChargeTabs.tsx @@ -0,0 +1,35 @@ +"use client"; + +import React from "react"; +import { cn } from "@/lib/utils"; +import { TABS } from "@/lib/constants/charge"; + +interface ChargeTabsProps { + activeTab: number; + setActiveTab: (index: number) => void; +} + +const ChargeTabs = ({ activeTab, setActiveTab }: ChargeTabsProps) => { + return ( +
    + {TABS.map((tab, i) => ( +
  • + +
  • + ))} +
+ ); +}; + +export default ChargeTabs; diff --git a/components/common/charge/DepositorNameContent.tsx b/components/common/charge/DepositorNameContent.tsx new file mode 100644 index 0000000..91c747c --- /dev/null +++ b/components/common/charge/DepositorNameContent.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React from "react"; +import { cn } from "@/lib/utils"; +import { isValidDepositorName } from "@/lib/validators"; + +export default function DepositorNameContent() { + const [name, setName] = React.useState(""); + + const handleNameChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (isValidDepositorName(value)) { + setName(value); + } + }; + + return ( +
+ {/* ── 입금자명 입력 ── */} +
+ 입금자명 +
+ +
+
+ + {/* ── 예시 이미지 ── */} +
+ 예시 +
+ + Toss 앱 입금자명 설정 예시 이미지 + +
+
+ + {/* ── 안내 가이드 (Frame 2612440) ── */} +
+ {/* 유의사항 */} +
+ 유의사항 +

+ 코매칭에 보내실 입금자명을 입력해 주세요. 입금자명은 한글이나 + 영문자로 6자 이내입니다. 특수문자는 사용할 수 없습니다. Toss 앱에 + 사전 설정된 입금자명을 입력하시면 간편합니다. 나중에 다시 수정할 수 + 있어요! +

+
+ + {/* FAQ */} +
+ + 왜 입금자명을 설정해야 하나요? + +

+ 모든 포인트 충전은 계좌이체로 진행되고 있어요. 충전 간 복잡함을 + 줄이기 위해, 사전에 입금자명을 설정하시면 더욱 편리하고 쾌적한 + 코매칭을 즐기실 수 있습니다. +

+
+
+
+ ); +} diff --git a/components/common/charge/QuickBundleCard.tsx b/components/common/charge/QuickBundleCard.tsx new file mode 100644 index 0000000..b7f26c5 --- /dev/null +++ b/components/common/charge/QuickBundleCard.tsx @@ -0,0 +1,51 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import { ICON_SIZE } from "@/lib/constants/charge"; + +interface QuickBundleCardProps { + title: string; + icon: string; + description: string; + bonus: string; + price: string; +} + +export default function QuickBundleCard({ + title, + icon, + description, + bonus, + price, +}: QuickBundleCardProps) { + return ( +
+
+ {title} +
+ {title} +
+ + {description} + + + {bonus} + +
+
+
+ +
+ ); +} diff --git a/components/common/charge/ShopContent.tsx b/components/common/charge/ShopContent.tsx new file mode 100644 index 0000000..eca3740 --- /dev/null +++ b/components/common/charge/ShopContent.tsx @@ -0,0 +1,108 @@ +"use client"; + +import React from "react"; +import { useItems } from "@/hooks/useItems"; +import ChargeInventoryCard from "./ChargeInventoryCard"; +import QuickBundleCard from "./QuickBundleCard"; +import { + QUICK_BUNDLES, + INDIVIDUAL_ITEMS, + BUNDLE_ITEMS, + USAGE_INFO, + BUSINESS_INFO, +} from "@/lib/constants/charge"; + +export default function ShopContent() { + const { data } = useItems(); + + const ticketCounts = { + matching: data?.data.matchingTicketCount ?? 0, + option: data?.data.optionTicketCount ?? 0, + }; + + return ( +
+ {/* ── 보유현황 카드 ── */} + + + {/* ── 빠른 구매 ── */} +
+ 빠른 구매 +
+ {QUICK_BUNDLES.map((bundle) => ( + + ))} +
+
+ + {/* ── 전체 ── */} +
+ 전체 +
+ {INDIVIDUAL_ITEMS.map((item) => ( +
+ + {item.label} + + +
+ ))} +
+
+ + {/* ── 번들 ── */} +
+ 번들 +
+ {BUNDLE_ITEMS.map((item) => ( +
+
+
+ + {item.title} + + + {item.description} + +
+ + {item.bonus} + +
+ +
+ ))} +
+
+ + {/* ── 이용안내 ── */} +
+ 이용안내 +
+

+ {USAGE_INFO} +

+

+ {BUSINESS_INFO} +

+
+
+
+ ); +} diff --git a/components/ui/drawer.tsx b/components/ui/drawer.tsx index 0e50b52..ae1eaa4 100644 --- a/components/ui/drawer.tsx +++ b/components/ui/drawer.tsx @@ -65,7 +65,7 @@ function DrawerContent({ className={cn( "group/drawer-content bg-background fixed z-50 flex h-auto flex-col", "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:max-h-[96vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b", - "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:max-h-[96vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", + "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mx-auto data-[vaul-drawer-direction=bottom]:max-h-[96vh] data-[vaul-drawer-direction=bottom]:max-w-[430px] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm", "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm", "after:hidden", diff --git a/lib/constants/charge.ts b/lib/constants/charge.ts new file mode 100644 index 0000000..9228882 --- /dev/null +++ b/lib/constants/charge.ts @@ -0,0 +1,136 @@ +/* ── 아이콘 사이즈 ── */ +export const ICON_SIZE = { + sm: 24, + lg: 80, +} as const; + +/* ── 보유현황 아이콘·라벨 ── */ +export const INVENTORY_ROWS = [ + { + icon: "/main/coin.png", + alt: "coin", + label: "보유 뽑기권", + key: "matching", + }, + { + icon: "/main/elec-bulb.png", + alt: "bulb", + label: "보유 아이템", + key: "option", + }, +] as const; + +/* ── 구매 가능 한도 (임시) ── */ +export const PURCHASE_LIMITS = { + matching: { current: 18, max: 30, label: "뽑기권" }, + option: { current: 42, max: 60, label: "아이템" }, +} as const; + +/* ── 빠른 구매 번들 카드 ── */ +export const QUICK_BUNDLES = [ + { + title: "실속 번들", + icon: "/main/coin.png", + description: "뽑기권 5개+옵션권 2개", + bonus: "옵션권 1개 무료 증정!", + price: "5,000원", + }, + { + title: "슈퍼 번들", + icon: "/main/coin.png", + description: "뽑기권 10개+옵션권 10개", + bonus: "옵션권 5개 무료 증정!", + price: "10,000원", + }, +] as const; + +/* ── 가격 상수 ── */ +export const ITEM_PRICES = { + matching: 1000, + option: 200, +} as const; + +/* ── 전체(개별) 아이템 ── */ +export const INDIVIDUAL_ITEMS = [ + { + label: "뽑기권 1개", + price: `${ITEM_PRICES.matching.toLocaleString()}원`, + }, + { + label: "옵션권 1개", + price: `${ITEM_PRICES.option.toLocaleString()}원`, + }, +] as const; + +/* ── 번들 아이템 ── */ +export const BUNDLE_ITEMS = [ + { + title: "미니 번들", + description: "옵션권 3개", + bonus: "옵션권 1개 무료 증정!", + price: "2,500원", + }, + { + title: "실속 번들", + description: "뽑기권 5개+옵션권 2개", + bonus: "옵션권 1개 무료 증정!", + price: "5,000원", + }, + { + title: "슈퍼 번들", + description: "뽑기권 10개+옵션권 10개", + bonus: "옵션권 5개 무료 증정!", + price: "10,000원", + }, +] as const; + +/* ── 이용안내 및 사업자 정보 ── */ +export const USAGE_INFO = `충전된 포인트의 소멸시효 기한은 충전 후 5년입니다. +1 포인트는 1원입니다. +충전한 포인트로 서비스를 이용할 수 있습니다. +포인트는 이벤트 포인트 먼저 사용되고, 유상 포인트가 사용됩니다. +이벤트 포인트는 유효기한이 임박한 순으로 먼저 사용됩니다. +유효기간은 포인트 충전내역을 통해 확인하실 수 있습니다.`; + +export const NOTICE_INFO = "결제 혹은 환불에 필요한 유의사항을 적습니다."; + +export const BUSINESS_INFO = + "대표이사 천승환 | 호스팅서비스사업자 코매칭 | 사업자 등록번호 843-27-01742 | 통신판매신고 2024-부천원미-2812 | 대표전화 010-3039-7387 | 경기도 부천시 조마루로 366번길 27, 401호"; + +/* ── 충전 내역 (임시) ── */ +export const CHARGE_HISTORY = [ + { + id: 1, + date: "2024.03.20", + orderId: "20240320-0001", + points: "뽑기권 5개", + paymentAmount: "5,000원", + status: "success", + statusLabel: "결제완료", + }, + { + id: 2, + date: "2024.03.18", + orderId: "20240318-0042", + points: "옵션권 10개", + paymentAmount: "5,000원", + status: "cancelled", + statusLabel: "결제취소", + }, + { + id: 3, + date: "2024.03.15", + orderId: "20240315-0012", + points: "뽑기권 1개 (이벤트)", + paymentAmount: "0원", + status: "event", + statusLabel: "이벤트 증정", + }, +] as const; + +/* ── 탭 정의 ── */ +export const TABS = [ + { label: "상점", title: "상점" }, + { label: "충전내역", title: "충전내역" }, + { label: "입금자명 설정", title: "입금자명 설정" }, +] as const; diff --git a/lib/validators.ts b/lib/validators.ts index aabcecf..daeca7a 100644 --- a/lib/validators.ts +++ b/lib/validators.ts @@ -14,3 +14,8 @@ export const validatePasswordPattern = (password: string): boolean => { /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]+$/; return passwordRegex.test(password); }; + +export const isValidDepositorName = (text: string): boolean => { + const regex = /^[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ]*$/; + return regex.test(text); +};