diff --git a/app/roulette/_components/Roulette.tsx b/app/roulette/_components/Roulette.tsx index bcaac29..bdcd2b9 100644 --- a/app/roulette/_components/Roulette.tsx +++ b/app/roulette/_components/Roulette.tsx @@ -4,33 +4,69 @@ import React, { forwardRef, useEffect, useImperativeHandle, - useRef, useState, } from "react"; import { cn } from "@/lib/utils"; export interface RouletteHandle { spin: () => void; + reset: () => void; } +export type RouletteType = "free" | "special"; + +export interface RouletteItem { + id: number; + label: string; +} + +// ========================================== +// 룰렛 타입별 설정 (이미지 & 상품 배열) +// ========================================== +const ROULETTE_CONFIG: Record< + RouletteType, + { imageSrc: string; items: RouletteItem[] } +> = { + free: { + imageSrc: "/roulette/roulette3.png", + items: [ + { id: 1, label: "옵션권 1장" }, + { id: 2, label: "옵션권 2장" }, + { id: 3, label: "뽑기권 1장" }, + { id: 4, label: "꽝" }, + { id: 5, label: "풀세트" }, + ], + }, + special: { + imageSrc: "/roulette/special_roulette.png", + items: [ + { id: 1, label: "옵션권 2장" }, + { id: 2, label: "옵션권 5장" }, + { id: 3, label: "뽑기권 1장" }, + { id: 4, label: "풀세트" }, + { id: 5, label: "뽑기권 5장" }, + { id: 6, label: "뽑기권 10장" }, + { id: 7, label: "1만원권 상품권" }, + { id: 8, label: "2만원권 상품권" }, + ], + }, +}; + interface RouletteProps { - onFinish?: (resultItem: number) => void; + type: RouletteType; + onFinish?: (item: RouletteItem) => void; onSpinChange?: (isSpinning: boolean) => void; className?: string; } -// 룰렛에 배치될 옵션들 (12시 경계선 기준 시계방향: 1, 2, 3, 4, 5) -// 0도 ~ 72도: 1번 -// 72도 ~ 144도: 2번 -// 144도 ~ 216도: 3번 -// 216도 ~ 288도: 4번 -// 288도 ~ 360도: 5번 -const ROULETTE_OPTIONS = [1, 2, 3, 4, 5]; - const Roulette = forwardRef( - ({ onFinish, onSpinChange, className }, ref) => { + ({ type, onFinish, onSpinChange, className }, ref) => { + const { imageSrc, items } = ROULETTE_CONFIG[type]; + const [isSpinning, setIsSpinning] = useState(false); const [rotation, setRotation] = useState(0); + const [transitionDuration, setTransitionDuration] = useState(7000); + // 언마운트 시 진행 중인 진동 즉시 중단 useEffect(() => { return () => { @@ -45,35 +81,27 @@ const Roulette = forwardRef( if (typeof window === "undefined" || !("vibrate" in navigator)) return; try { - // 기존 진동 즉시 정지 navigator.vibrate(0); - // [진동, 대기, 진동, 대기...] 형식의 패턴 생성 (총 7초 회전과 싱크) const pattern: number[] = []; let elapsed = 0; - let pause = 75; // 초기 빠른 회전 시 대기 간격 + let pause = 75; - // 0초부터 ~6.3초까지 감속되는 틱 진동 패턴 생성 while (elapsed < 6300) { - // 손끝에 확실히 느껴지도록 25ms ~ 45ms 강도로 점진적 조절 const vibDuration = elapsed < 3000 ? 30 : elapsed < 5000 ? 38 : 45; pattern.push(vibDuration); pattern.push(pause); elapsed += vibDuration + pause; - // 회전이 느려질수록 진동 간격을 점차 넓힘 pause = Math.min(800, Math.floor(pause * 1.09)); } - // 마지막 당첨 순간(7초)까지 대기 시간을 마지막 pause에 합산 const remainingWait = Math.max(100, 7000 - elapsed); if (pattern.length > 0) { pattern[pattern.length - 1] += remainingWait; } - // 7초 정지 순간 당첨 축하 묵직한 더블 햅틱 (80ms 진동 -> 80ms 쉼 -> 150ms 진동) + // 7초 정지 순간 당첨 축하 묵직한 더블 햅틱 pattern.push(80, 80, 150); - - // 버튼 클릭(사용자 인터랙션) 스택에서 네이티브 패턴 통째로 즉시 전달 navigator.vibrate(pattern); } catch (err) { console.error("Vibration failed:", err); @@ -83,37 +111,32 @@ const Roulette = forwardRef( const spin = () => { if (isSpinning) return; setIsSpinning(true); + setTransitionDuration(7000); onSpinChange?.(true); - // 햅틱 진동 실행 triggerHapticFeedback(); - // 1. 당첨될 인덱스 랜덤 선택 (0 ~ 4) - const resultIndex = Math.floor(Math.random() * ROULETTE_OPTIONS.length); - const resultItem = ROULETTE_OPTIONS[resultIndex]; + // 당첨 아이템 랜덤 선택 + const resultIndex = Math.floor(Math.random() * items.length); + const resultItem = items[resultIndex]; - // 2. 1칸당 차지하는 각도 (360 / 5 = 72도) - const segmentAngle = 360 / ROULETTE_OPTIONS.length; - - // 3. 당첨 각도 계산 - // 초기 0도가 '경계선'이므로, 각 번호의 중앙은 (인덱스 * 72) + 36도에 위치합니다. - // 이 중앙을 12시(360도)로 끌고 오기 위한 목표 각도: + // 1칸당 각도 계산 + const segmentAngle = 360 / items.length; + // 당첨 칸의 중앙을 12시 방향으로 가져오는 각도 const itemCenterAngle = resultIndex * segmentAngle + segmentAngle / 2; const targetAngle = 360 - itemCenterAngle; - // 4. 경계선 아슬아슬한 곳까지 도달 (-31도 ~ +31도 오차) - // 칸의 중앙 기준 좌우 경계선(±36도) 직전까지 아슬아슬하게 회전 + // 경계선 아슬아슬한 오차 (-31도 ~ +31도) const randomOffset = Math.floor(Math.random() * 63) - 31; - const spins = 10; // 기본 10바퀴 회전 + const spins = 10; - // 누적 각도 계산 const currentBase = rotation - (rotation % 360); const finalRotation = currentBase + spins * 360 + targetAngle + randomOffset; setRotation(finalRotation); - // 5. 회전 완료 콜백 (7초 뒤) + // 7초 뒤 결과 콜백 setTimeout(() => { setIsSpinning(false); onSpinChange?.(false); @@ -121,8 +144,16 @@ const Roulette = forwardRef( }, 7000); }; + const reset = () => { + setTransitionDuration(0); // 0초로 설정하여 즉시 회전 원복 + setRotation(0); + setIsSpinning(false); + onSpinChange?.(false); + }; + useImperativeHandle(ref, () => ({ spin, + reset, })); return ( @@ -132,7 +163,7 @@ const Roulette = forwardRef( className, )} > - {/* 룰렛 상단 하트 포인터 (SVG) */} + {/* 룰렛 상단 하트 포인터 */}
( {/* 고정된 그림자: 회전하지 않음 */}
- {/* 룰렛 이미지만 회전 (rounded-full과 will-change 추가로 회전 시 네 모서리 돌출 방지) */} + {/* 룰렛 이미지만 회전 */}
룰렛 원판 void; } export default function RouletteProbabilityBottomSheet({ trigger, defaultTab = "free", className, + open, + onOpenChange, }: RouletteProbabilityBottomSheetProps) { const [activeTab, setActiveTab] = useState<"free" | "special">(defaultTab); @@ -48,8 +59,8 @@ export default function RouletteProbabilityBottomSheet({ activeTab === "free" ? FREE_ROULETTE_ITEMS : SPECIAL_ROULETTE_ITEMS; return ( - - {trigger} + + {trigger && {trigger}} = { + "옵션권 1장": "/roulette/item/option_ticket_1.png", + "옵션권 2장": "/roulette/item/option_ticket_2.png", + "옵션권 5장": "/roulette/item/option_ticket_5.png", + 꽝: "/roulette/item/miss.png", // TODO: 꽝 이미지 추가 필요 + "뽑기권 1장": "/roulette/item/draw_ticket_1.png", + "뽑기권 5장": "/roulette/item/draw_ticket_5.png", + "뽑기권 10장": "/roulette/item/draw_ticket_10.png", + 풀세트: "/roulette/item/full_set.png", + "1만원권 상품권": "/roulette/item/gift_card_10000.png", // TODO: 상품권 이미지 추가 필요 + "2만원권 상품권": "/roulette/item/gift_card_20000.png", // TODO: 상품권 이미지 추가 필요 +}; + +// 아이템 설명 텍스트 +const ITEM_DESCRIPTION_MAP: Record = { + "옵션권 1장": "매칭 시 옵션 1개를 선택할 수 있어요", + "옵션권 2장": "매칭 시 옵션 2개를 선택할 수 있어요", + "옵션권 5장": "매칭 시 옵션 5개를 선택할 수 있어요", + 꽝: "아쉽지만 다음 기회에!", + "뽑기권 1장": "새로운 매칭 기회가 생겼어요", + "뽑기권 5장": "새로운 매칭 기회 5번이 생겼어요", + "뽑기권 10장": "새로운 매칭 기회 10번이 생겼어요", + 풀세트: "뽑기권 1장 + 옵션권 3장 획득!", + "1만원권 상품권": "1만원 상품권이 지급됩니다", + "2만원권 상품권": "2만원 상품권이 지급됩니다", +}; + +// ========================================== +// 컨페티 조각 (좌/우) +// ========================================== +const ConfettiLeft = () => ( +
+
+
+
+
+
+
+
+
+
+); + +const ConfettiRight = () => ( +
+
+
+
+
+
+
+
+); + +// ========================================== +// 당첨 결과 모달 +// ========================================== +interface RouletteResultModalProps { + open: boolean; + onClose: () => void; + item: RouletteItem | null; +} + +export default function RouletteResultModal({ + open, + onClose, + item, +}: RouletteResultModalProps) { + if (!item) return null; + + const imageSrc = ITEM_IMAGE_MAP[item.label] ?? "/roulette/prizes/miss.png"; + const description = ITEM_DESCRIPTION_MAP[item.label] ?? ""; + + return ( + !o && onClose()}> + + 룰렛 당첨 결과 + + 당첨된 보상을 확인하세요. + + + {/* 찐 모달 본체 */} +
+ {/* 컨페티 (장식용이므로 얘네만 absolute 유지) */} + + + + {/* 상단 텍스트 영역 */} +
+ + 축하해요! + +
+ + {/* 중앙 이미지 영역 (유동적 여백) */} +
+
+ {item.label} +
+ {/* 바닥 그림자 */} +
+
+ + {/* 하단 텍스트 영역 */} +
+ + {item.label} + + + {item.label === "꽝" ? description : "보상이 바로 지급되었어요."} + +
+ + {/* 하단 버튼 영역 */} +
+ + {item.label !== "꽝" && ( + + )} +
+
+ +
+ ); +} diff --git a/app/roulette/free/_components/ScreenRouletteFree.tsx b/app/roulette/free/_components/ScreenRouletteFree.tsx index 891eab9..b29516e 100644 --- a/app/roulette/free/_components/ScreenRouletteFree.tsx +++ b/app/roulette/free/_components/ScreenRouletteFree.tsx @@ -3,8 +3,12 @@ import React, { useRef, useState } from "react"; import { CircleAlert } from "lucide-react"; import RouletteHeader from "../../_components/RouletteHeader"; import Button from "@/components/ui/Button"; -import Roulette, { RouletteHandle } from "../../_components/Roulette"; +import Roulette, { + RouletteHandle, + RouletteItem, +} from "../../_components/Roulette"; import RouletteProbabilityBottomSheet from "../../_components/RouletteProbabilityBottomSheet"; +import RouletteResultModal from "../../_components/RouletteResultModal"; // TODO: 실제 API 연동 시 대체 const MOCK_REMAINING_CHANCES = 1; @@ -15,6 +19,9 @@ const ScreenRouletteFree = () => { const rouletteRef = useRef(null); const [isSpinning, setIsSpinning] = useState(false); + const [resultItem, setResultItem] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isProbabilityOpen, setIsProbabilityOpen] = useState(false); const handleSpin = () => { if (!hasChances || isSpinning) return; @@ -26,9 +33,12 @@ const ScreenRouletteFree = () => { {/* Top Group: Header, Badge, Tagline */}
{ {/* Center: Roulette Wheel */} console.log("당첨 인덱스:", idx)} + onFinish={(item) => { + setIsProbabilityOpen(false); // 바텀시트 닫기 + setResultItem(item); + setIsModalOpen(true); + }} /> {/* Bottom Group: Spin Button + Notice */} @@ -85,6 +100,15 @@ const ScreenRouletteFree = () => { 1일 1회 참여할 수 있어요
+ + { + setIsModalOpen(false); + rouletteRef.current?.reset(); + }} + item={resultItem} + />
); }; diff --git a/app/roulette/special/[amount]/page.tsx b/app/roulette/special/[amount]/page.tsx new file mode 100644 index 0000000..df747ed --- /dev/null +++ b/app/roulette/special/[amount]/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import { useRouter, redirect } from "next/navigation"; +import { X, Info } from "lucide-react"; +import Button from "@/components/ui/Button"; + +export default function SpecialAmountPage({ + params, +}: { + params: Promise<{ amount: string }>; +}) { + const router = useRouter(); + const { amount } = React.use(params); + + // 유효하지 않은 값이면 렌더링 즉시 튕겨냄 (useEffect 불필요) + if (amount !== "10000" && amount !== "20000") { + redirect("/roulette/special"); + } + + const displayAmount = amount === "20000" ? "20,000" : "10,000"; + + return ( +
+ {/* Decorative Vectors (별/빛 모양 등) */} +
+
+
+
+
+
+
+
+ + {/* 닫기 버튼 */} +
+ +
+ +
+ {/* 축하 메시지 */} +

+ 당첨을 축하해요! +

+ + {/* 당첨 금액 */} +

+ {displayAmount}원 +

+ + {/* 바우처 라벨 구분선 (Voucher container) */} +
+
+
+ + 상품권 + +
+
+
+ + {/* 바우처 이미지 */} +
+ {`${displayAmount}원 +
+ + {/* 사용 방법 안내 (Redemption instructions container) */} +
+ {/* Way 1 */} +
+
+
+ + 방법 1 + +
+ + 마이페이지 확인 + +
+

+ 마이페이지 - 내 아이템에서 상품권을 확인하세요. +

+
+ + {/* Way 2 */} +
+
+
+ + 방법 2 + +
+ + 현장 데스크 교환 + +
+

+ { + "축제 기간 중 총학생회 부스에 방문하여\n당첨 화면을 보여주고 실물 상품권으로 교환하세요." + } +

+
+
+
+ +
+ + {/* 하단 영역 (버튼 + 유의사항) */} +
+ +
+ + + 결제 취소 시 지급된 보상이 회수될 수 있어요 + +
+
+
+ ); +} diff --git a/app/roulette/special/_components/ScreenRouletteSpecial.tsx b/app/roulette/special/_components/ScreenRouletteSpecial.tsx index e6ed206..ded02f5 100644 --- a/app/roulette/special/_components/ScreenRouletteSpecial.tsx +++ b/app/roulette/special/_components/ScreenRouletteSpecial.tsx @@ -1,21 +1,30 @@ "use client"; import React, { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { CircleAlert } from "lucide-react"; import RouletteHeader from "../../_components/RouletteHeader"; import Button from "@/components/ui/Button"; -import Roulette, { RouletteHandle } from "../../_components/Roulette"; +import Roulette, { + RouletteHandle, + RouletteItem, +} from "../../_components/Roulette"; import RouletteProbabilityBottomSheet from "../../_components/RouletteProbabilityBottomSheet"; import SpecialRouletteChanceCard from "./SpecialRouletteChanceCard"; +import RouletteResultModal from "../../_components/RouletteResultModal"; // TODO: 실제 API 연동 시 대체 const MOCK_REMAINING_CHANCES = 1; const ScreenRouletteSpecial = () => { + const router = useRouter(); const remainingChances = MOCK_REMAINING_CHANCES; const hasChances = remainingChances > 0; const rouletteRef = useRef(null); const [isSpinning, setIsSpinning] = useState(false); + const [resultItem, setResultItem] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isProbabilityOpen, setIsProbabilityOpen] = useState(false); const handleSpin = () => { if (!hasChances || isSpinning) return; @@ -31,6 +40,8 @@ const ScreenRouletteSpecial = () => { sidebar={ { {/* Center: Roulette Wheel */} console.log("당첨 인덱스:", idx)} + onFinish={(item) => { + setIsProbabilityOpen(false); // 바텀시트 닫기 + setResultItem(item); + + if (item.label === "1만원권 상품권") { + router.push("/roulette/special/10000"); + } else if (item.label === "2만원권 상품권") { + router.push("/roulette/special/20000"); + } else { + setIsModalOpen(true); + } + }} /> {/* Bottom Group: Spin Button + Notice */} @@ -72,6 +95,15 @@ const ScreenRouletteSpecial = () => { 결제 취소 시 지급된 보상이 회수될 수 있어요
+ + { + setIsModalOpen(false); + rouletteRef.current?.reset(); + }} + item={resultItem} + />
); }; diff --git a/public/roulette/item/draw_ticket_1.png b/public/roulette/item/draw_ticket_1.png new file mode 100644 index 0000000..ecf52b8 Binary files /dev/null and b/public/roulette/item/draw_ticket_1.png differ diff --git a/public/roulette/item/draw_ticket_10.png b/public/roulette/item/draw_ticket_10.png new file mode 100644 index 0000000..195f59a Binary files /dev/null and b/public/roulette/item/draw_ticket_10.png differ diff --git a/public/roulette/item/draw_ticket_5.png b/public/roulette/item/draw_ticket_5.png new file mode 100644 index 0000000..8489f6e Binary files /dev/null and b/public/roulette/item/draw_ticket_5.png differ diff --git a/public/roulette/item/full_set.png b/public/roulette/item/full_set.png new file mode 100644 index 0000000..7905333 Binary files /dev/null and b/public/roulette/item/full_set.png differ diff --git a/public/roulette/item/gift_card_10000.png b/public/roulette/item/gift_card_10000.png new file mode 100644 index 0000000..65eb744 Binary files /dev/null and b/public/roulette/item/gift_card_10000.png differ diff --git a/public/roulette/item/gift_card_20000.png b/public/roulette/item/gift_card_20000.png new file mode 100644 index 0000000..7b5fcff Binary files /dev/null and b/public/roulette/item/gift_card_20000.png differ diff --git a/public/roulette/item/option_ticket_1.png b/public/roulette/item/option_ticket_1.png new file mode 100644 index 0000000..4adf189 Binary files /dev/null and b/public/roulette/item/option_ticket_1.png differ diff --git a/public/roulette/item/option_ticket_2.png b/public/roulette/item/option_ticket_2.png new file mode 100644 index 0000000..440f6e4 Binary files /dev/null and b/public/roulette/item/option_ticket_2.png differ diff --git a/public/roulette/item/option_ticket_5.png b/public/roulette/item/option_ticket_5.png new file mode 100644 index 0000000..fe657ca Binary files /dev/null and b/public/roulette/item/option_ticket_5.png differ diff --git a/public/roulette/special_roulette.png b/public/roulette/special_roulette.png new file mode 100644 index 0000000..590d5bc Binary files /dev/null and b/public/roulette/special_roulette.png differ