import React, { useState, useEffect, useMemo, useRef } from 'react';
import { initializeApp } from 'firebase/app';
import {
getAuth,
signInAnonymously,
onAuthStateChanged,
signInWithCustomToken,
signOut,
GoogleAuthProvider,
signInWithPopup,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
updateProfile,
updatePassword,
RecaptchaVerifier,
signInWithPhoneNumber
} from 'firebase/auth';
import {
getFirestore,
doc,
setDoc,
updateDoc,
onSnapshot,
collection,
addDoc,
serverTimestamp,
getDoc
} from 'firebase/firestore';
import {
MapPin,
Utensils,
Coffee,
Beer,
Music,
Scissors,
Sparkles,
User,
Ticket,
MessageCircle,
ChevronRight,
X,
Star,
Map as MapIcon,
RotateCcw,
Home,
PlusCircle,
Heart,
Send,
Store,
Clock,
QrCode,
LogOut,
Mail,
Lock,
Phone,
Plus,
MessageSquare,
Edit,
HelpCircle,
Bell,
Gift,
ChevronLeft,
CreditCard,
AlertCircle,
GraduationCap,
Image as ImageIcon,
Users,
CheckCircle,
ChevronDown,
ChevronUp,
PawPrint,
Plane,
Navigation,
Siren, // For Report
Smartphone, // For Phone Auth
Camera, // For Image Upload
Car, // For Grab
Shield, // For Admin
Briefcase, // For Boss
Calendar // For DOB
} from 'lucide-react';
// --- Firebase Configuration ---
const firebaseConfig = JSON.parse(__firebase_config);
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
// --- Global Constants ---
const fontStyle = { fontFamily: "'Pretendard', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif" };
const GOOGLE_MAPS_API_KEY = ""; // TODO: 나중에 여기에 실제 Google Maps API Key를 넣으세요.
const LANGUAGES = [
{ code: 'ko', flag: '🇰🇷' },
{ code: 'en', flag: '🇺🇸' },
{ code: 'vn', flag: '🇻🇳' }
];
// Updated Categories with Pet and Travel
const CATEGORIES_DATA = [
{ id: 'food', Icon: Utensils },
{ id: 'cafe', Icon: Coffee },
{ id: 'pub', Icon: Beer },
{ id: 'club', Icon: Music },
{ id: 'massage', Icon: Sparkles },
{ id: 'barber', Icon: Scissors },
{ id: 'beauty', Icon: User },
{ id: 'shopping', Icon: Store },
{ id: 'pet', Icon: PawPrint },
{ id: 'travel', Icon: Plane },
];
const CITIES = [
{ id: 'hcm', name: '호치민 (HCM)', icon: '🏙️', lat: 10.7769, lng: 106.7009 },
{ id: 'hanoi', name: '하노이', icon: '🏯', lat: 21.0285, lng: 105.8542 },
{ id: 'danang', name: '다낭', icon: '🏖️', lat: 16.0544, lng: 108.2022 },
{ id: 'nhatrang', name: '나트랑', icon: '🌴', lat: 12.2388, lng: 109.1967 },
];
// 레슨용 지역 상수 (달랏 포함)
const LESSON_LOCATIONS = [
{ id: 'hcm', name: '호치민' },
{ id: 'hanoi', name: '하노이' },
{ id: 'danang', name: '다낭' },
{ id: 'nhatrang', name: '나트랑' },
{ id: 'dalat', name: '달랏' },
];
// Updated MOCK_BUSINESSES with REAL Coordinates for Google Maps
const MOCK_BUSINESSES = [
{
id: 'b1',
name: 'Pho Quynh',
category: 'food',
city: 'hcm',
rating: 4.8,
reviewCount: 128,
image: 'https://images.unsplash.com/photo-1582878826629-29b7ad1cdc43?auto=format&fit=crop&q=80&w=800',
desc: {
ko: '부이비엔 근처 가장 유명한 24시간 쌀국수 맛집.',
en: 'Famous 24h Pho restaurant near Bui Vien walking street.',
vn: 'Quán phở 24h nổi tiếng nhất gần phố đi bộ Bùi Viện.'
},
hours: '10:00 ~ 02:00',
phone: '028 3836 8515',
address: '323 Phạm Ngũ Lão, Phường Phạm Ngũ Lão, Quận 1, Hồ Chí Minh',
coupon: {
ko: '음료 1잔 무료',
en: 'Free Drink',
vn: 'Miễn phí 1 đồ uống'
},
couponDesc: {
ko: '직원에게 이 화면을 보여주세요.',
en: 'Show this screen to the staff.',
vn: 'Vui lòng đưa màn hình này cho nhân viên.'
},
lat: 10.7676, lng: 106.6935 // Real coordinates
},
{
id: 'b2',
name: 'Bun Cha 145',
category: 'food',
city: 'hcm',
rating: 4.5,
reviewCount: 856,
image: 'https://images.unsplash.com/photo-1513558161293-cdaf765ed2fd?auto=format&fit=crop&q=80&w=800',
desc: { ko: '부이비엔 분짜 맛집', en: 'Bun Cha 145', vn: 'Bun Cha Ngon' },
hours: '12:30 ~ 20:00',
phone: '028 3837 3474',
address: '145 Bùi Viện, Phường Phạm Ngũ Lão, Quận 1, Hồ Chí Minh',
coupon: { ko: '10% 할인', en: '10% Discount', vn: 'Giảm giá 10%' },
couponDesc: { ko: '메인 메뉴 주문 시', en: 'Main dish order', vn: 'Khi gọi món chính' },
lat: 10.7671, lng: 106.6943
},
{ id: 'b3', name: 'Pizza 4P\'s', category: 'food', city: 'hcm', image: 'https://images.unsplash.com/photo-1574071318508-1cdbab80d002?auto=format&fit=crop&q=80&w=800', desc: { ko: '화덕 피자', en: 'Pizza', vn: 'Pizza' }, hours: '11:00~22:00', phone: '1900 6043', address: '8/15 Le Thanh Ton', coupon: { ko: '디저트 증정', en: 'Free Dessert', vn: 'Tặng tráng miệng' }, couponDesc: { ko: '설명', en: 'Desc', vn: 'Mô tả' }, lat: 10.7797, lng: 106.7045 },
{ id: 'b4', name: 'Cong Cafe', category: 'cafe', city: 'danang', image: 'https://images.unsplash.com/photo-1509042239860-f550ce710b93?auto=format&fit=crop&q=80&w=800', desc: { ko: '코코넛 커피', en: 'Coconut Coffee', vn: 'Cà phê cốt dừa' }, hours: '07:00~23:00', phone: '0236 6553', address: '98 Bach Dang', coupon: { ko: '사이즈 업', en: 'Size up', vn: 'Up size' }, couponDesc: { ko: '설명', en: 'Desc', vn: 'Mô tả' }, lat: 16.0688, lng: 108.2238 },
{ id: 'b5', name: 'Miu Miu Spa', category: 'massage', city: 'hcm', image: 'https://images.unsplash.com/photo-1600334089648-b0d9d3028eb2?auto=format&fit=crop&q=80&w=800', desc: { ko: '마사지', en: 'Massage', vn: 'Massage' }, hours: '09:30~23:00', phone: '028 6659', address: '4 Chu Manh Trinh', coupon: { ko: '20% 할인', en: '20% DC', vn: 'Giảm 20%' }, couponDesc: { ko: '설명', en: 'Desc', vn: 'Mô tả' }, lat: 10.7810, lng: 106.7040 },
];
const MOCK_POSTS = [
{
id: 'p1',
author: '여행자123',
city: 'hcm',
title: '호치민 1군 맛집 추천 좀 해주세요!',
content: '부모님 모시고 가는데 깔끔한 곳 찾습니다.',
likes: 12,
isLiked: false,
comments: [],
timestamp: '방금 전'
}
];
// Updated Mock Winners to support Multi-language
const MOCK_WINNERS = {
ko: ["🔥 [호치민] user82**님 'Pizza 4P's' 무료 피자 당첨!"],
en: ["🔥 [HCMC] user82** won 'Free Pizza' at 'Pizza 4P's'!"],
vn: ["🔥 [TP.HCM] user82** đã trúng 'Pizza miễn phí' tại 'Pizza 4P's'!"]
};
const COUNTRY_CODES = [
{ code: '+84', name: '베트남', flag: '🇻🇳' },
{ code: '+82', name: '대한민국', flag: '🇰🇷' },
{ code: '+1', name: '미국', flag: '🇺🇸' },
];
const SLOT_OPTIONS = [
{ count: 1, price: 2, label: '1개 확장' },
{ count: 3, price: 3, label: '3개 확장' },
{ count: 5, price: 5, label: '5개 확장' },
{ count: 10, price: 8, label: '10개 확장' },
];
const RESIDENCE_OPTIONS = [
{ id: 'hanoi', label: '하노이' },
{ id: 'danang', label: '다낭' },
{ id: 'nhatrang', label: '나트랑' },
{ id: 'dalat', label: '달랏' },
{ id: 'hcm', label: '호치민' },
{ id: 'custom', label: '직접 입력' }
];
const TRANSLATIONS = {
ko: {
home: '룰렛', map: '지도', coupon: '내 쿠폰함', community: '커뮤니티', mypage: '마이',
login_needed: '로그인이 필요해요', login_btn: '로그인 / 회원가입', login: '로그인', signup: '회원가입',
id_placeholder: '아이디', pw_placeholder: '비밀번호', email_placeholder: '이메일 (연락용)', phone_placeholder: '핸드폰 번호',
spin_left: '오늘 남은 기회', daily_user: '오늘 {n}명이 룰렛으로 결정했어요!', lineup: '이달의 라인업', click_info: '클릭하여 업체 정보 보기', boss_only: '사장님 전용',
store_inquiry: '입점 신청하기', inquiry_desc: '100만 동으로 우리 가게를\nDealBox 룰렛에 올리세요!', apply: '입점 신청하기',
spin_msg_login: '로그인하고 룰렛 돌리기', spin_msg_no_chance: '스핀 기회가 없나요? 광고보고 충전하기 (+1)',
slot_add: '슬롯 추가', my_slot: 'MY 쿠폰 슬롯', spin_free: '무제한', spin_no: '기회없음',
coupon_validity: '* 쿠폰은 발급 후 24시간 동안 유효합니다.', use_coupon: '사용하기', save_coupon: '쿠폰 담기',
available: '사용가능', hours_left: '시간 남음', congrats: '🎉 당첨을 축하합니다!', saved_msg: '쿠폰함에 저장하여 매장 방문 시 사용하세요!',
boss_check: '사장님 확인 완료', boss_check_title: '쿠폰 사용 확인 (사장님 보여주기)',
write_title: '글쓰기', write_placeholder_title: '제목', write_placeholder_content: '내용 입력', register: '등록하기',
comments: '댓글', likes: '공감', city_select: '지역 선택', traveler_talk: '트래블러 톡', talk_placeholder: '정보 공유하기...',
cat_food: '맛집', cat_cafe: '카페', cat_pub: '펍/바', cat_club: '클럽', cat_massage: '마사지', cat_barber: '이발소', cat_beauty: '뷰티', cat_shopping: '쇼핑', cat_pet: '애완동물', cat_travel: '여행사',
tag_food: '오늘 뭐 먹지?', tag_cafe: '분위기 좋은 카페', tag_pub: '한잔 할까?', tag_club: '오늘 밤 파티?', tag_massage: '피로 풀기', tag_barber: '깔끔하게 변신', tag_beauty: '더 예뻐지기', tag_shopping: '기념품 & 쇼핑', tag_pet: '댕댕이와 함께', tag_travel: '여행 떠나볼까?',
city_hcm: '호치민', city_hanoi: '하노이', city_danang: '다낭', city_nhatrang: '나트랑',
menu_edit: '내 정보 수정', menu_inquiry: '신고/문의 센터', menu_notice: '공지사항', menu_event: '이벤트', menu_lesson: '개인레슨', logout: '로그아웃',
lesson_write: '레슨 등록하기', lesson_price: '시간당 가격 (VND)', lesson_contact: '연락처', lesson_apply: '신청하기', lesson_applicants: '신청자',
lesson_apply_title: '레슨 신청', lesson_apply_btn: '신청 완료', lesson_apply_msg_placeholder: '선생님께 남길 메시지',
lesson_applicants_view: '신청자 명단',
google_fail: '구글 로그인 실패', login_fail: '로그인 실패', signup_success: '회원가입 성공',
map_view: '구글 지도로 보기', review: '리뷰',
ticker_format: "🎉 [{city}] {user}님 '{shop}' {coupon} 당첨!",
business_hours: '영업시간:', address_label: '주소:', phone_label: '전화:',
map_guide: '지도를 움직여 업체를 확인하세요',
map_key_missing: '지도를 불러오지 못했습니다. API 키를 확인해주세요.',
grab_go: 'Grab으로 이동', report: '신고하기', report_title: '신고하기', report_reason: '신고 사유', report_btn: '신고 접수',
push_setting: '알림 설정', admin_page: '관리자 페이지', boss_page: '사장님 페이지',
zalo_login: 'Zalo로 로그인', phone_verify: '인증',
name_placeholder: '이름', dob_placeholder: '생년월일 (YYYY-MM-DD)', residence_label: '거주지역'
},
en: {
home: 'Roulette', map: 'Map', coupon: 'My Coupons', community: 'Community', mypage: 'My',
login_needed: 'Login Required', login_btn: 'Login / Signup', login: 'Login', signup: 'Signup',
id_placeholder: 'ID', pw_placeholder: 'Password', email_placeholder: 'Email', phone_placeholder: 'Phone Number',
spin_left: 'Spins Left', daily_user: '{n} users decided with Roulette today!', lineup: 'Lineup of the Month', click_info: 'Click for info', boss_only: 'Partners',
store_inquiry: 'Join DealBox', inquiry_desc: 'Promote your shop on DealBox\nfor 1M VND!', apply: 'Apply to Join',
spin_msg_login: 'Login to Spin', spin_msg_no_chance: 'No spins left? Watch Ad to recharge (+1)',
slot_add: 'Add Slots', my_slot: 'My Slots', spin_free: 'Free', spin_no: '0',
coupon_validity: '* Coupon valid for 24 hours after issuance.', use_coupon: 'Use Coupon', save_coupon: 'Save',
available: 'Available', hours_left: 'h left', congrats: '🎉 Congratulations!', saved_msg: 'Saved to your wallet!',
boss_check: 'Verified by Staff', boss_check_title: 'Use Coupon (Show Staff)',
write_title: 'Write Post', write_placeholder_title: 'Title', write_placeholder_content: 'Content', register: 'Post',
comments: 'Comments', likes: 'Likes', city_select: 'Select City', traveler_talk: 'Traveler Talk', talk_placeholder: 'Share info...',
cat_food: 'Food', cat_cafe: 'Cafe', cat_pub: 'Pub/Bar', cat_club: 'Club', cat_massage: 'Massage', cat_barber: 'Barber', cat_beauty: 'Beauty', cat_shopping: 'Shopping', cat_pet: 'Pet', cat_travel: 'Travel',
tag_food: 'Hungry?', tag_cafe: 'Nice Vibes', tag_pub: 'Drink?', tag_club: 'Party Tonight?', tag_massage: 'Relax', tag_barber: 'Haircut', tag_beauty: 'Beauty', tag_shopping: 'Shopping', tag_pet: 'With Pets', tag_travel: 'Let\'s go!',
city_hcm: 'HCMC', city_hanoi: 'Hanoi', city_danang: 'Danang', city_nhatrang: 'Nha Trang',
menu_edit: 'Edit Profile', menu_inquiry: 'Report/Support', menu_notice: 'Notices', menu_event: 'Events', menu_lesson: 'Lessons', logout: 'Logout',
lesson_write: 'Post Lesson', lesson_price: 'Price/Hr (VND)', lesson_contact: 'Contact', lesson_apply: 'Apply', lesson_applicants: 'Applicants',
lesson_apply_title: 'Apply for Lesson', lesson_apply_btn: 'Submit', lesson_apply_msg_placeholder: 'Message to Tutor',
lesson_applicants_view: 'Applicant List',
google_fail: 'Google Login Failed', login_fail: 'Login Failed', signup_success: 'Signup Success',
map_view: 'Open in Google Maps', review: 'Reviews',
ticker_format: "🎉 [{city}] {user} won '{coupon}' at '{shop}'!",
business_hours: 'Hours:', address_label: 'Address:', phone_label: 'Phone:',
map_guide: 'Move map to explore',
map_key_missing: 'Map unavailable. Check API Key.',
grab_go: 'Open Grab', report: 'Report', report_title: 'Report', report_reason: 'Reason', report_btn: 'Submit',
push_setting: 'Push Notifications', admin_page: 'Admin Page', boss_page: 'Partner Page',
zalo_login: 'Login with Zalo', phone_verify: 'Verify',
name_placeholder: 'Name', dob_placeholder: 'DOB (YYYY-MM-DD)', residence_label: 'Residence'
},
vn: {
home: 'Vòng quay', map: 'Bản đồ', coupon: 'Kho Coupon', community: 'Cộng đồng', mypage: 'Tôi',
login_needed: 'Cần đăng nhập', login_btn: 'Đăng nhập / Đăng ký', login: 'Đăng nhập', signup: 'Đăng ký',
id_placeholder: 'Tài khoản', pw_placeholder: 'Mật khẩu', email_placeholder: 'Email', phone_placeholder: 'Số điện thoại',
spin_left: 'Lượt quay', daily_user: '{n} người đã quay hôm nay!', lineup: 'Địa điểm nổi bật', click_info: 'Xem chi tiết', boss_only: 'Đối tác',
store_inquiry: 'Hợp tác', inquiry_desc: 'Quảng bá cửa hàng trên DealBox\nchỉ với 1tr VNĐ!', apply: 'Đăng ký ngay',
spin_msg_login: 'Đăng nhập để quay', spin_msg_no_chance: 'Hết lượt? Xem QC để thêm lượt (+1)',
slot_add: 'Thêm ô', my_slot: 'Ô chứa Coupon', spin_free: 'Miễn phí', spin_no: 'Hết',
coupon_validity: '* Coupon có hiệu lực trong 24h.', use_coupon: 'Sử dụng', save_coupon: 'Lưu',
available: 'Có sẵn', hours_left: 'giờ', congrats: '🎉 Chúc mừng!', saved_msg: 'Đã lưu vào ví!',
boss_check: 'Xác nhận', boss_check_title: 'Dùng Coupon (Đưa nhân viên)',
write_title: 'Viết bài', write_placeholder_title: 'Tiêu đề', write_placeholder_content: 'Nội dung', register: 'Đăng',
comments: 'Bình luận', likes: 'Thích', city_select: 'Chọn TP', traveler_talk: 'Chat Du lịch', talk_placeholder: 'Chia sẻ...',
cat_food: 'Ăn uống', cat_cafe: 'Cafe', cat_pub: 'Pub/Bar', cat_club: 'Club', cat_massage: 'Massage', cat_barber: 'Hớt tóc', cat_beauty: 'Làm đẹp', cat_shopping: 'Mua sắm', cat_pet: 'Thú cưng', cat_travel: 'Du lịch',
tag_food: 'Đói chưa?', tag_cafe: 'Cafe đẹp', tag_pub: 'Đi nhậu?', tag_club: 'Quẩy nào', tag_massage: 'Thư giãn', tag_barber: 'Cắt tóc', tag_beauty: 'Làm đẹp', tag_shopping: 'Mua sắm', tag_pet: 'Với thú cưng', tag_travel: 'Đi du lịch nào',
city_hcm: 'TP.HCM', city_hanoi: 'Hà Nội', city_danang: 'Đà Nẵng', city_nhatrang: 'Nha Trang',
menu_edit: 'Sửa hồ sơ', menu_inquiry: 'Hỗ trợ', menu_notice: 'Thông báo', menu_event: 'Sự kiện', menu_lesson: 'Gia sư', logout: 'Đăng xuất',
lesson_write: 'Đăng bài', lesson_price: 'Giá/giờ (VND)', lesson_contact: 'Liên hệ', lesson_apply: 'Đăng ký', lesson_applicants: 'Người đăng ký',
lesson_apply_title: 'Đăng ký học', lesson_apply_btn: 'Gửi', lesson_apply_msg_placeholder: 'Tin nhắn cho giáo viên',
lesson_applicants_view: 'Danh sách đăng ký',
google_fail: 'Lỗi đăng nhập Google', login_fail: 'Lỗi đăng nhập', signup_success: 'Đăng ký thành công',
map_view: 'Mở Google Maps', review: 'Đánh giá',
ticker_format: "🎉 [{city}] {user} đã trúng '{coupon}' tại '{shop}'!",
business_hours: 'Giờ mở cửa:', address_label: 'Địa chỉ:', phone_label: 'Điện thoại:',
map_guide: 'Di chuyển bản đồ để khám phá',
map_key_missing: 'Không thể tải bản đồ. Kiểm tra API Key.',
grab_go: 'Mở Grab', report: 'Báo cáo', report_title: 'Báo cáo', report_reason: 'Lý do', report_btn: 'Gửi',
push_setting: 'Thông báo', admin_page: 'Trang Quản trị', boss_page: 'Trang Đối tác',
zalo_login: 'Đăng nhập Zalo', phone_verify: 'Xác thực',
name_placeholder: 'Tên', dob_placeholder: 'Ngày sinh (YYYY-MM-DD)', residence_label: 'Nơi ở'
}
};
// --- Confetti Component ---
const Confetti = () => {
// ... (Same as before)
const particles = Array.from({ length: 50 }).map((_, i) => {
const style = {
left: '50%',
top: '50%',
'--x': `${(Math.random() - 0.5) * 600}px`,
'--y': `${(Math.random() - 0.5) * 600}px`,
'--rotate': `${Math.random() * 360}deg`,
backgroundColor: ['#EC4899', '#8B5CF6', '#3B82F6', '#F59E0B', '#10B981'][Math.floor(Math.random() * 5)],
animationDelay: `${Math.random() * 0.2}s`
};
return
;
});
return (
{particles}
);
};
// --- Helper Components ---
const Modal = ({ isOpen, onClose, children, title, hideClose = false, bgColor="bg-[#1f2937]" }) => {
if (!isOpen) return null;
return (
{!hideClose &&
}
{title &&
{title}
}
{children}
);
};
const BusinessCard = ({ data, onClick, lang = 'ko' }) => {
const currentDesc = typeof data.desc === 'string' ? data.desc : (data.desc[lang] || data.desc['ko']);
const currentCoupon = typeof data.coupon === 'string' ? data.coupon : (data.coupon[lang] || data.coupon['ko']);
return (
onClick(data)} className="bg-[#1f2937] rounded-xl p-3 flex items-center gap-4 mb-3 border border-gray-800 hover:border-pink-500/50 transition-all cursor-pointer active:scale-95 touch-manipulation">
{data.name}
{currentCoupon}
);
};
const RouletteWheel = ({ items, onSpinComplete, canSpin, t }) => {
const [spinning, setSpinning] = useState(false);
const [rotation, setRotation] = useState(0);
const handleSpin = () => {
if (!canSpin || spinning) return;
setSpinning(true);
const newRotation = rotation + 1800 + Math.random() * 360;
setRotation(newRotation);
setTimeout(() => {
setSpinning(false);
const degrees = newRotation % 360;
const sliceAngle = 360 / items.length;
const index = Math.floor(((360 - degrees) % 360) / sliceAngle);
onSpinComplete(items[index]);
}, 3000);
};
return (
{/* SVG content omitted for brevity as it's same as before */}
{items.map((_, i) => {
const sliceAngle = 360 / items.length;
const x1 = 100 + 100 * Math.cos(Math.PI * (i * sliceAngle) / 180);
const y1 = 100 + 100 * Math.sin(Math.PI * (i * sliceAngle) / 180);
const x2 = 100 + 100 * Math.cos(Math.PI * ((i + 1) * sliceAngle) / 180);
const y2 = 100 + 100 * Math.sin(Math.PI * ((i + 1) * sliceAngle) / 180);
return (
);
})}
{items.map((item, i) => {
const sliceAngle = 360 / items.length;
const textAngle = (i * sliceAngle) + sliceAngle / 2;
const tx = 100 + 65 * Math.cos(Math.PI * textAngle / 180);
const ty = 100 + 65 * Math.sin(Math.PI * textAngle / 180);
return (
{item.image ? (
<>
>
) : (
)}
{item.name}
);
})}
SPIN
{canSpin ? t('spin_free') : t('spin_no')}
);
};
// Real-time Winner Ticker Component
const WinnerTicker = ({ lang }) => {
const [winners, setWinners] = useState([]);
useEffect(() => {
const winnersRef = collection(db, 'artifacts', appId, 'public', 'data', 'winners');
const unsubscribe = onSnapshot(winnersRef, (snapshot) => {
let newWinners = snapshot.docs.map(doc => doc.data());
newWinners.sort((a, b) => {
const tA = a.timestamp?.seconds || 0;
const tB = b.timestamp?.seconds || 0;
return tB - tA;
});
newWinners = newWinners.slice(0, 10);
if (newWinners.length > 0) setWinners(newWinners);
else setWinners([]);
}, (error) => console.log("Ticker Error:", error));
return () => unsubscribe();
}, []);
const formatWinner = (item) => {
if (typeof item === 'string') return item;
const currentLang = TRANSLATIONS[lang] ? lang : 'ko';
const format = TRANSLATIONS[currentLang].ticker_format;
const city = TRANSLATIONS[currentLang][`city_${item.cityId}`] || item.cityId;
const couponName = (typeof item.coupon === 'object') ? (item.coupon[currentLang] || item.coupon['ko']) : item.coupon;
return format
.replace('{city}', city)
.replace('{user}', item.userName)
.replace('{shop}', item.businessName)
.replace('{coupon}', couponName);
};
let displayList = [];
if (winners.length > 0) {
displayList = winners.map(formatWinner);
} else {
displayList = MOCK_WINNERS[lang] || MOCK_WINNERS['ko'];
}
let animatedList = [];
if (displayList.length > 0) {
animatedList = [...displayList];
while (animatedList.length < 20) {
animatedList = [...animatedList, ...displayList];
}
}
const duration = Math.max(animatedList.length * 2, 20);
return (
{animatedList.map((text, i) => ({text} ))}
);
};
export default function DealBoxApp() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [selectedCity, setSelectedCity] = useState('hcm');
const [selectedCategory, setSelectedCategory] = useState('food');
const [activeTab, setActiveTab] = useState('home');
const [viewMode, setViewMode] = useState('roulette');
const [lang, setLang] = useState('ko');
const [showLangMenu, setShowLangMenu] = useState(false);
const [myPageView, setMyPageView] = useState('main');
const [showCityModal, setShowCityModal] = useState(false);
const [selectedBusiness, setSelectedBusiness] = useState(null);
const [spinResult, setSpinResult] = useState(null);
const [isAdWatching, setIsAdWatching] = useState(false);
const [showConfetti, setShowConfetti] = useState(false);
const [showChat, setShowChat] = useState(false);
const [chatMessages, setChatMessages] = useState([]);
const [chatInput, setChatInput] = useState('');
const [showInquiryModal, setShowInquiryModal] = useState(false);
const [showWriteModal, setShowWriteModal] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [showSlotShop, setShowSlotShop] = useState(false);
const [focusedBusiness, setFocusedBusiness] = useState(null);
// New Feature States
const [showReportModal, setShowReportModal] = useState(false);
const [reportTarget, setReportTarget] = useState(null);
const [reportReason, setReportReason] = useState('');
const [pushEnabled, setPushEnabled] = useState(false);
const [deviceId, setDeviceId] = useState('');
const [userRole, setUserRole] = useState('user');
const [postImagePreview, setPostImagePreview] = useState(null);
// Map Logic
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const markersRef = useRef([]);
// Lesson States
const [showLessonWriteModal, setShowLessonWriteModal] = useState(false);
const [showLessonApplyModal, setShowLessonApplyModal] = useState(false);
const [showApplicantListModal, setShowApplicantListModal] = useState(false);
const [selectedLesson, setSelectedLesson] = useState(null);
const [lessons, setLessons] = useState([]);
// Lesson Forms
const [lessonForm, setLessonForm] = useState({ title: '', category: 'academic', city: 'hanoi', price: '', desc: '' });
const [lessonApplyForm, setLessonApplyForm] = useState({ name: '', region: 'hanoi', phone: '', messengerId: '' });
const [lessonApplicants, setLessonApplicants] = useState([]);
const [applicantCount, setApplicantCount] = useState(0);
const [posts, setPosts] = useState(MOCK_POSTS);
const [expandedPostId, setExpandedPostId] = useState(null);
const [dailyParticipants, setDailyParticipants] = useState(0);
const [selectedCoupon, setSelectedCoupon] = useState(null);
const [authMode, setAuthMode] = useState('login');
const [inquiryForm, setInquiryForm] = useState({ storeName: '', category: 'food', city: 'hcm', discountInfo: '', contact: '', representativeName: '', googleMapUrl: '' });
const [postForm, setPostForm] = useState({ title: '', content: '' });
const [commentText, setCommentText] = useState('');
const [loginId, setLoginId] = useState('');
const [loginPassword, setLoginPassword] = useState('');
// Updated Signup Form
const [signupForm, setSignupForm] = useState({
id: '', password: '', email: '',
name: '', dob: '', residence: 'hanoi', residenceCustom: '',
countryCode: '+84', phone: ''
});
const [changePw, setChangePw] = useState('');
const [myInquiry, setMyInquiry] = useState('');
const [userData, setUserData] = useState({ spinsLeft: 3, maxCoupons: 3, lastSpinDate: null, savedCoupons: [], usedCoupons: [] });
const [savedBusinesses, setSavedBusinesses] = useState([]);
const fileInputRef = useRef(null);
// Category Scroll Logic
const scrollRef = React.useRef(null);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const onMouseDown = (e) => { setIsDragging(true); setStartX(e.pageX - scrollRef.current.offsetLeft); setScrollLeft(scrollRef.current.scrollLeft); };
const onMouseLeave = () => { setIsDragging(false); };
const onMouseUp = () => { setIsDragging(false); };
const onMouseMove = (e) => { if (!isDragging) return; e.preventDefault(); const x = e.pageX - scrollRef.current.offsetLeft; const walk = (x - startX) * 2; scrollRef.current.scrollLeft = scrollLeft - walk; };
const t = (key) => (TRANSLATIONS[lang] ? TRANSLATIONS[lang][key] : TRANSLATIONS['ko'][key]) || '';
const categories = useMemo(() => CATEGORIES_DATA.map(cat => ({ ...cat, name: t(`cat_${cat.id}`), tagline: t(`tag_${cat.id}`) })), [lang]);
const currentBusinesses = useMemo(() => MOCK_BUSINESSES.filter(b => b.city === selectedCity && b.category === selectedCategory), [selectedCity, selectedCategory]);
const rouletteItems = useMemo(() => currentBusinesses.length > 0 ? currentBusinesses.slice(0, 6) : [{name: '업체 없음', id: 'none'}], [currentBusinesses]);
const filteredPosts = useMemo(() => posts.filter(p => p.city === selectedCity), [posts, selectedCity]);
// --- Handlers ---
const handleCitySelect = (cityId) => { setSelectedCity(cityId); setShowCityModal(false); };
const handleSpinComplete = async (winner) => {
if (!user || winner.id === 'none') return;
if (userData.savedCoupons?.includes(winner.id)) { alert("이미 쿠폰을 받은 업체입니다."); return; }
if (userData.usedCoupons?.includes(winner.id)) { alert("이미 쿠폰을 사용한 업체입니다."); return; }
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data'), { spinsLeft: userData.spinsLeft - 1 });
const maskedName = (user.displayName || 'User').substring(0, 2) + '**';
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'winners'), {
cityId: selectedCity, userName: maskedName, businessName: winner.name, coupon: winner.coupon, timestamp: serverTimestamp()
});
setSpinResult(winner);
setShowConfetti(true);
setTimeout(() => setShowConfetti(false), 2500);
};
const handleSaveCoupon = async (businessId) => {
if (!user) return;
if (userData.savedCoupons.length >= userData.maxCoupons) { alert("슬롯이 가득 찼습니다!"); return; }
const userDocRef = doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data');
if (!userData.savedCoupons.includes(businessId) && !userData.usedCoupons?.includes(businessId)) {
await updateDoc(userDocRef, { savedCoupons: [...userData.savedCoupons, businessId] });
alert("쿠폰이 저장되었습니다.");
} else { alert("이미 저장된 쿠폰입니다."); }
};
const handleUseCoupon = (business) => setSelectedCoupon(business);
const handleUseCouponConfirm = async () => {
if (!user || !selectedCoupon) return;
const newSaved = userData.savedCoupons.filter(id => id !== selectedCoupon.id);
const newUsed = [...(userData.usedCoupons || []), selectedCoupon.id];
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data'), { savedCoupons: newSaved, usedCoupons: newUsed });
alert("쿠폰 사용 완료!");
setSelectedCoupon(null);
};
const handleBuySlot = async (option) => {
if (!user || user.isAnonymous) { setShowAuthModal(true); return; }
if (userData.maxCoupons + option.count > 15) { alert("최대 슬롯 제한."); return; }
if (confirm(`${option.label} ($${option.price}) 결제하시겠습니까?`)) {
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data'), { maxCoupons: userData.maxCoupons + option.count });
alert("슬롯 확장 완료!");
setShowSlotShop(false);
}
};
const handleWatchAd = async () => {
setIsAdWatching(true);
setTimeout(async () => {
setIsAdWatching(false);
if (!user) return;
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data'), { spinsLeft: (userData.spinsLeft || 0) + 1 });
alert("스핀 기회가 충전되었습니다!");
}, 2000);
};
const handleSendChat = async () => {
if (!chatInput.trim() || !user) return;
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', `chat_${selectedCity}`), {
text: chatInput, userId: user.uid, userName: user.displayName || 'Traveler', timestamp: serverTimestamp()
});
setChatInput('');
};
const handleGoogleLogin = async () => { try { await signInWithPopup(auth, new GoogleAuthProvider()); setShowAuthModal(false); } catch (error) { alert(t('google_fail') + ": " + error.message); } };
const handleIdLogin = async () => { try { await signInWithEmailAndPassword(auth, `${loginId}@dealbox.vn`, loginPassword); setShowAuthModal(false); } catch (error) { alert(t('login_fail')); } };
const handlePhoneLogin = () => {
alert("휴대폰 인증은 실제 배포 환경에서 Firebase Phone Auth를 통해 작동합니다.\n(인증 완료 시뮬레이션)");
};
const handleZaloLogin = () => {
alert("Zalo 로그인은 실제 배포 시 Zalo SDK 연동이 필요합니다.\n(로그인 성공 시뮬레이션)");
};
const handleSignup = async () => {
// Check Required Fields
if (!signupForm.id || !signupForm.email || !signupForm.password || !signupForm.name || !signupForm.dob || !signupForm.phone) return alert("모든 정보를 입력해주세요.");
try {
const cred = await createUserWithEmailAndPassword(auth, `${signupForm.id}@dealbox.vn`, signupForm.password);
await updateProfile(cred.user, { displayName: signupForm.id }); // Using ID as DisplayName for now
// Determine Residence
const finalResidence = signupForm.residence === 'custom' ? signupForm.residenceCustom : signupForm.residence;
await setDoc(doc(db, 'artifacts', appId, 'users', cred.user.uid, 'profile', 'data'), {
id: signupForm.id,
name: signupForm.name,
dob: signupForm.dob,
contactEmail: signupForm.email,
phone: `${signupForm.countryCode} ${signupForm.phone}`,
residence: finalResidence,
spinsLeft: 3, maxCoupons: 3, savedCoupons: [], usedCoupons: [], lastSpinDate: new Date().toISOString().split('T')[0],
role: 'user',
deviceId: deviceId
});
alert(t('signup_success')); setShowAuthModal(false);
} catch (error) { alert("회원가입 실패: " + error.message); }
};
const handleLogout = async () => { try { await signOut(auth); alert("로그아웃 완료"); } catch (e) {} };
const handleLike = (postId) => { setPosts(posts.map(p => { if (p.id === postId) return { ...p, likes: (p.isLiked ? p.likes - 1 : p.likes + 1), isLiked: !p.isLiked }; return p; })); };
const handleAddComment = (postId) => { if (!commentText.trim()) return; setPosts(posts.map(p => { if (p.id === postId) return { ...p, comments: [...p.comments, { id: Date.now(), user: '나', text: commentText }] }; return p; })); setCommentText(''); };
const handleSubmitPost = () => {
if (!postForm.title || !postForm.content) return;
setPosts([{ id: Date.now(), author: user.displayName || '나', city: selectedCity, title: postForm.title, content: postForm.content, likes: 0, comments: [], timestamp: '방금 전', imageUrl: postImagePreview }, ...posts]);
setShowWriteModal(false);
setPostForm({ title: '', content: '' });
setPostImagePreview(null);
};
const handleSubmitInquiry = () => {
if (!inquiryForm.storeName || !inquiryForm.discountInfo || !inquiryForm.contact || !inquiryForm.representativeName) { alert("필수 항목을 모두 입력해주세요."); return; }
alert("승인 대기중입니다.");
setShowInquiryModal(false);
setInquiryForm({ storeName: '', category: 'food', city: 'hcm', discountInfo: '', contact: '', representativeName: '', googleMapUrl: '' });
};
const handleChangePassword = async () => {
if(!changePw) return;
try { await updatePassword(user, changePw); alert("비밀번호가 변경되었습니다."); setChangePw(''); } catch(e) { alert("변경 실패: " + e.message); }
};
const handleSubmitMyInquiry = async () => {
if(!myInquiry) return;
// Store report to Firestore with detailed info for Admin
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'reports'), {
type: 'inquiry_1on1',
content: myInquiry,
userId: user.uid,
userName: user.displayName,
status: 'pending', // For Admin to track
timestamp: serverTimestamp()
});
alert("문의/신고가 접수되었습니다.");
setMyInquiry('');
};
const handleWritePost = () => { if(!user || user.isAnonymous) { alert("회원만 글을 쓸 수 있습니다."); setShowAuthModal(true); return; } setShowWriteModal(true); };
const handleWriteLesson = () => { if(!user || user.isAnonymous) { alert("회원만 등록할 수 있습니다."); setShowAuthModal(true); return; } setShowLessonWriteModal(true); };
const handleSubmitLesson = async () => {
if (!lessonForm.title || !lessonForm.price || !lessonForm.desc) { alert("모든 항목을 입력해주세요."); return; }
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'lessons'), {
...lessonForm, authorId: user.uid, tutor: user.displayName || 'Teacher', timestamp: serverTimestamp()
});
setShowLessonWriteModal(false);
setLessonForm({ title: '', category: 'academic', city: 'hanoi', price: '', desc: '' });
alert("레슨이 등록되었습니다.");
};
const handleApplyLesson = (lesson) => { if(!user || user.isAnonymous) { setShowAuthModal(true); return; } setSelectedLesson(lesson); setShowLessonApplyModal(true); };
const handleShowApplicants = (lesson) => { setSelectedLesson(lesson); setShowApplicantListModal(true); };
const handleSubmitApplication = async () => {
if (!lessonApplyForm.name || !lessonApplyForm.phone || !lessonApplyForm.messengerId) { alert("필수 정보를 입력해주세요."); return; }
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', `lesson_applicants_${selectedLesson.id}`), {
...lessonApplyForm, applicantId: user.uid, timestamp: serverTimestamp()
});
alert("신청이 완료되었습니다.");
setShowLessonApplyModal(false);
setLessonApplyForm({ name: '', region: 'hanoi', phone: '', messengerId: '' });
};
// --- New Features Handlers ---
const handleReport = (post) => {
setReportTarget(post);
setShowReportModal(true);
};
const submitReport = async () => {
if(!reportReason) return alert("사유를 입력해주세요");
// Store Report with 'pending' status for Admin
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'reports'), {
type: 'post_report',
targetId: reportTarget.id,
reason: reportReason,
reporterId: user.uid,
status: 'pending',
timestamp: serverTimestamp()
});
alert("신고가 접수되었습니다.");
setShowReportModal(false);
setReportReason('');
};
const handlePushToggle = () => {
if (!("Notification" in window)) {
alert("이 브라우저는 알림을 지원하지 않습니다.");
return;
}
if (Notification.permission === "granted") {
setPushEnabled(!pushEnabled);
alert("알림 설정이 변경되었습니다.");
} else if (Notification.permission !== "denied") {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
setPushEnabled(true);
alert("알림이 켜졌습니다.");
}
});
} else {
alert("알림 권한이 차단되어 있습니다. 브라우저 설정에서 허용해주세요.");
}
};
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setPostImagePreview(reader.result);
};
reader.readAsDataURL(file);
}
};
const openGrab = () => {
let grabUrl = "https://grab.onelink.me/2695613898?pid=website&c=footer_logo&is_retargeting=true";
// If selected business has coordinates, try to use them (Note: Deep linking to specific coords in universal link varies by OS)
// This is a standard universal link fallback to store if app not present
if (selectedBusiness && selectedBusiness.lat && selectedBusiness.lng) {
// Attempt to use a google maps link that Grab might intercept or user can choose app
// But standard Grab deep link is 'grab://open?screenType=BOOKING'
// Here we use the universal link as requested for general compatibility
}
if (confirm("Grab 앱을 여시겠습니까? (설치되어 있지 않으면 스토어로 이동합니다)")) {
window.open(grabUrl, '_blank');
}
};
// --- Logic Hooks ---
useEffect(() => {
// Generate Device ID if not exists
let storedDeviceId = localStorage.getItem('dealbox_device_id');
if (!storedDeviceId) {
storedDeviceId = 'device_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('dealbox_device_id', storedDeviceId);
}
setDeviceId(storedDeviceId);
const initAuth = async () => {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) await signInWithCustomToken(auth, __initial_auth_token);
else await signInAnonymously(auth);
};
initAuth();
onAuthStateChanged(auth, (u) => { setUser(u); setLoading(false); });
const today = new Date().toLocaleDateString();
const storedDate = localStorage.getItem('dealbox_date');
let count = parseInt(localStorage.getItem('dealbox_count'));
if (storedDate !== today || !count) {
count = Math.floor(Math.random() * (1000 - 50 + 1)) + 50;
localStorage.setItem('dealbox_date', today);
localStorage.setItem('dealbox_count', count);
}
setDailyParticipants(count);
}, []);
// ... (Existing Hooks for data fetching kept as is)
useEffect(() => {
if (!user) return;
const lessonRef = collection(db, 'artifacts', appId, 'public', 'data', 'lessons');
const unsubscribe = onSnapshot(lessonRef, (snapshot) => {
const lessonList = snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
lessonList.sort((a, b) => (b.timestamp?.seconds || 0) - (a.timestamp?.seconds || 0));
setLessons(lessonList);
});
return () => unsubscribe();
}, [user]);
useEffect(() => {
if (!selectedLesson || !user) return;
const applyRef = collection(db, 'artifacts', appId, 'public', 'data', `lesson_applicants_${selectedLesson.id}`);
const unsubscribe = onSnapshot(applyRef, (snapshot) => {
setApplicantCount(snapshot.size);
setLessonApplicants(snapshot.docs.map(d => ({ id: d.id, ...d.data() })));
});
return () => unsubscribe();
}, [selectedLesson, user]);
useEffect(() => {
if (!user) return;
const userDocRef = doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data');
const unsubscribe = onSnapshot(userDocRef, (docSnap) => {
if (docSnap.exists()) {
const data = docSnap.data();
if (data.role) setUserRole(data.role);
const today = new Date().toISOString().split('T')[0];
if (data.lastSpinDate !== today) {
updateDoc(userDocRef, { spinsLeft: 3, lastSpinDate: today });
setUserData({ ...data, spinsLeft: 3, lastSpinDate: today });
} else { setUserData(data); }
} else {
setDoc(userDocRef, { spinsLeft: 3, maxCoupons: 3, lastSpinDate: new Date().toISOString().split('T')[0], savedCoupons: [], usedCoupons: [], role: 'user', deviceId: deviceId });
setUserData({ spinsLeft: 3, maxCoupons: 3, lastSpinDate: new Date().toISOString().split('T')[0], savedCoupons: [], usedCoupons: [] });
}
});
return () => unsubscribe();
}, [user, deviceId]);
useEffect(() => {
if (userData.savedCoupons?.length > 0) {
setSavedBusinesses(MOCK_BUSINESSES.filter(b => userData.savedCoupons.includes(b.id)));
} else { setSavedBusinesses([]); }
}, [userData.savedCoupons]);
useEffect(() => {
if (!user || !showChat) return;
const chatRef = collection(db, 'artifacts', appId, 'public', 'data', `chat_${selectedCity}`);
const unsubscribe = onSnapshot(chatRef, (snapshot) => {
let msgs = snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
msgs.sort((a, b) => (b.timestamp?.seconds || 0) - (a.timestamp?.seconds || 0));
msgs = msgs.slice(0, 50);
setChatMessages(msgs.reverse());
});
return () => unsubscribe();
}, [selectedCity, showChat, user]);
// Google Maps Hooks
useEffect(() => {
if (!window.google && viewMode === 'map') {
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&callback=initMap&libraries=places`;
script.async = true;
script.defer = true;
window.initMap = () => { console.log("Google Maps loaded"); };
document.head.appendChild(script);
}
}, [viewMode]);
useEffect(() => {
if (viewMode === 'map' && mapRef.current && window.google && !mapInstanceRef.current) {
const cityInfo = CITIES.find(c => c.id === selectedCity);
const center = { lat: cityInfo?.lat || 10.7769, lng: cityInfo?.lng || 106.7009 };
mapInstanceRef.current = new window.google.maps.Map(mapRef.current, { center: center, zoom: 14, disableDefaultUI: true, zoomControl: true });
}
}, [viewMode, selectedCity]);
useEffect(() => {
if (!mapInstanceRef.current || !window.google) return;
markersRef.current.forEach(marker => marker.setMap(null));
markersRef.current = [];
currentBusinesses.forEach(biz => {
const marker = new window.google.maps.Marker({
position: { lat: biz.lat, lng: biz.lng }, map: mapInstanceRef.current, title: biz.name, icon: { url: "http://maps.google.com/mapfiles/ms/icons/pink-dot.png" }
});
marker.addListener("click", () => {
setSelectedBusiness(biz);
mapInstanceRef.current.panTo(marker.getPosition());
mapInstanceRef.current.setZoom(16);
});
markersRef.current.push(marker);
});
if (!focusedBusiness) {
const cityInfo = CITIES.find(c => c.id === selectedCity);
if (cityInfo) {
mapInstanceRef.current.panTo({ lat: cityInfo.lat, lng: cityInfo.lng });
mapInstanceRef.current.setZoom(14);
}
}
}, [currentBusinesses, selectedCity, focusedBusiness]);
useEffect(() => {
if(focusedBusiness && mapInstanceRef.current) {
mapInstanceRef.current.panTo({ lat: focusedBusiness.lat, lng: focusedBusiness.lng });
mapInstanceRef.current.setZoom(16);
}
}, [focusedBusiness]);
// ... (Renderers stay mostly same, added new UI elements in return) ...
// For brevity, only showing changed parts in Renderers within the full file context
const renderMap = () => {
return (
{currentBusinesses.map((biz, i) => (
setFocusedBusiness(biz)} className={`min-w-[280px] bg-[#1f2937] rounded-xl p-3 border transition-all cursor-pointer shadow-xl ${focusedBusiness?.id === biz.id ? 'border-pink-500 ring-2 ring-pink-500/50' : 'border-gray-700'}`}>
{biz.name} {typeof biz.coupon === 'string' ? biz.coupon : (biz.coupon[lang] || biz.coupon['ko'])}
))}
{t('map_guide')}
);
};
const renderHome = () => (
{t('my_slot')} setShowSlotShop(true)} className="w-5 h-5 rounded-full bg-[#F59E0B] flex items-center justify-center text-black hover:bg-yellow-400 transition-colors shadow-lg shadow-yellow-500/30 animate-pulse"> {Array.from({ length: 10 }).map((_, i) => ( i < userData.maxCoupons && (
) ))}
setViewMode('roulette')} className={`px-3 py-1 rounded-full text-[10px] font-bold transition-all ${viewMode === 'roulette' ? 'bg-pink-500 text-white shadow' : 'text-gray-400'}`}>{t('home')} setViewMode('map')} className={`px-3 py-1 rounded-full text-[10px] font-bold transition-all ${viewMode === 'map' ? 'bg-pink-500 text-white shadow' : 'text-gray-400'}`}>{t('map')}
{categories.map(cat => (
setSelectedCategory(cat.id)} className={`flex-shrink-0 flex flex-col items-center min-w-[68px] gap-2 transition-all group active:scale-95`}>
{cat.name} ))}
{viewMode === 'roulette' ? (
{t(`city_${selectedCity}`)} • {categories.find(c => c.id === selectedCategory)?.name}
{categories.find(c => c.id === selectedCategory)?.tagline}
0 && rouletteItems[0].id !== 'none'} spinCount={userData.spinsLeft} onSpinComplete={handleSpinComplete} lang={lang} t={t} />
{!user || user.isAnonymous ?
setShowAuthModal(true)} className="text-pink-400 text-xs underline cursor-pointer p-2">{t('spin_msg_login')} : userData.spinsLeft <= 0 ?
{t('spin_msg_no_chance')} :
{t('spin_left')}: {userData.spinsLeft}회
}
{t('daily_user').replace('{n}', dailyParticipants)}
) : ( renderMap() )}
{t('lineup')} {t('click_info')}
{currentBusinesses.length > 0 ? currentBusinesses.map(biz =>
) :
등록된 업체가 없습니다.
}
setShowInquiryModal(true)} className="mt-6 p-5 rounded-xl bg-[#1f2937] border border-gray-700 text-center relative overflow-hidden cursor-pointer active:scale-98 transition-transform">
{t('boss_only')}
🛍️ {t('store_inquiry')}
{t('inquiry_desc')}
{t('apply')}
);
// ... CouponBox, Community, MyPage (with added Report Button in MyPage) ...
const renderCommunity = () => (
{t('community')} 📍 {t(`city_${selectedCity}`)}
{filteredPosts.length > 0 ? filteredPosts.map(post => (
setExpandedPostId(expandedPostId === post.id ? null : post.id)}>
{t(`city_${post.city}`)}
{post.title}
{post.likes}
{post.comments.length}
{expandedPostId === post.id ? : }
{expandedPostId === post.id && (
{post.content}
{post.imageUrl &&
}
{post.timestamp}
{ e.stopPropagation(); handleLike(post.id); }} className={`flex items-center gap-1 text-xs px-3 py-1.5 rounded-full border ${post.isLiked ? 'border-pink-500 text-pink-500 bg-pink-500/10' : 'border-gray-600 text-gray-400 hover:bg-gray-700'}`}> {t('likes')} {post.likes}
{ e.stopPropagation(); handleReport(post); }} className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-full border border-gray-600 text-red-400 hover:bg-red-900/30"> {t('report')}
{post.comments.length > 0 ? ( post.comments.map(c => (
{c.user} {c.text}
)) ) :
첫 댓글을 남겨보세요!
}
setCommentText(e.target.value)} placeholder={t('comments')} className="flex-1 bg-gray-700 border-none rounded-lg px-3 py-2 text-xs text-white focus:ring-1 focus:ring-pink-500 outline-none" /> { e.stopPropagation(); handleAddComment(post.id); }} className="bg-pink-600 text-white p-2 rounded-lg">
)}
)) :
이 지역에 작성된 글이 없습니다.
}
);
const renderCouponBox = () => (
{t('coupon')} {userData.savedCoupons?.length || 0} / {userData.maxCoupons} 사용중
{savedBusinesses.length > 0 ? savedBusinesses.map(biz => (
{biz.name} {t(`city_${biz.city}`)} {t('available')}
{typeof biz.coupon === 'string' ? biz.coupon : (biz.coupon[lang] || biz.coupon['ko'])}
23{t('hours_left')}
handleUseCoupon(biz)} className="bg-[#111827] text-white text-xs font-bold px-4 py-2.5 rounded-lg hover:bg-black transition-colors active:scale-95">{t('use_coupon')}
)) :
}
);
const renderMyPage = () => {
const subPageHeader = (title) => (
setMyPageView('main')} className="bg-gray-800 p-2 rounded-full text-white">
{title}
);
if (myPageView === 'edit') return ( {subPageHeader(t('menu_edit'))}
);
// Updated Inquiry/Report Center
if (myPageView === 'inquiry') return (
{subPageHeader(t('menu_inquiry'))}
신고/문의 하기
서비스 이용 중 불편한 점이나 신고할 내용이 있다면 관리자에게 직접 보내주세요.
문의/신고 접수
);
if (myPageView === 'notice') return ( {subPageHeader(t('menu_notice'))}
서비스 오픈 안내 DealBox 서비스가 정식 오픈되었습니다.
2025.11.20 );
if (myPageView === 'event') return ( {subPageHeader(t('menu_event'))}
오픈 기념 룰렛 이벤트 매일매일 무료 스핀을 드립니다!
진행중 );
if (myPageView === 'lesson') return (
{subPageHeader(t('menu_lesson'))}
{t('lesson_write')}
{lessons.length > 0 ? lessons.map(l => (
{LESSON_LOCATIONS.find(loc => loc.id === l.city)?.name} • {l.category}
{l.title} {Number(l.price).toLocaleString()} VND/H
{l.desc}
{l.tutor} {user && user.uid === l.authorId && ( handleShowApplicants(l)} className="bg-gray-700 px-3 py-1.5 rounded text-yellow-400 hover:bg-gray-600 transition-colors font-bold flex items-center gap-1"> 신청확인 )} handleApplyLesson(l)} className="bg-pink-600 px-3 py-1.5 rounded text-white hover:bg-pink-500 transition-colors font-bold">{t('lesson_apply')}
)) :
등록된 레슨이 없습니다.
}
);
return (
{!user || user.isAnonymous ? (
{t('login_needed')}
setShowAuthModal(true)} className="w-full bg-pink-500 text-white py-3.5 rounded-xl font-bold text-base mt-6 active:scale-98 transition-transform">{t('login_btn')}
) : (
<>
{user.displayName ? user.displayName[0] : 'D'}
{user.displayName || 'Member'}
{t('spin_left')}: {userData.spinsLeft}회
setMyPageView('edit')} className="w-full bg-[#1f2937] hover:bg-gray-800 p-4 rounded-xl flex items-center justify-between text-gray-300 transition-colors text-base font-medium active:bg-gray-800">{t('menu_edit')}
setMyPageView('inquiry')} className="w-full bg-[#1f2937] hover:bg-gray-800 p-4 rounded-xl flex items-center justify-between text-gray-300 transition-colors text-base font-medium active:bg-gray-800">{t('menu_inquiry')}
setMyPageView('notice')} className="w-full bg-[#1f2937] hover:bg-gray-800 p-4 rounded-xl flex items-center justify-between text-gray-300 transition-colors text-base font-medium active:bg-gray-800">{t('menu_notice')}
setMyPageView('event')} className="w-full bg-[#1f2937] hover:bg-gray-800 p-4 rounded-xl flex items-center justify-between text-gray-300 transition-colors text-base font-medium active:bg-gray-800">{t('menu_event')}
setMyPageView('lesson')} className="w-full bg-[#1f2937] hover:bg-gray-800 p-4 rounded-xl flex items-center justify-between text-gray-300 transition-colors text-base font-medium active:bg-gray-800">{t('menu_lesson')}
{/* Admin & Boss Access Buttons */}
{userRole === 'admin' && (
alert("관리자 페이지로 이동합니다 (준비중)")} className="w-full bg-blue-900/50 border border-blue-700 p-3 rounded-xl flex items-center justify-center gap-2 text-blue-200 font-bold hover:bg-blue-900"> {t('admin_page')}
)}
{(userRole === 'boss' || userRole === 'admin') && (
alert("사장님 페이지로 이동합니다 (준비중)")} className="w-full bg-yellow-900/50 border border-yellow-700 p-3 rounded-xl flex items-center justify-center gap-2 text-yellow-200 font-bold hover:bg-yellow-900"> {t('boss_page')}
)}
{t('logout')}
>
)}
);
};
if (loading) return Loading...
;
return (
{showConfetti &&
}
{/* Header Content (Same as before) */}
setShowLangMenu(!showLangMenu)} className="flex items-center justify-center w-8 h-8 rounded-full bg-[#1f2937] border border-gray-700 text-lg active:scale-95 hover:bg-gray-800 transition-colors">{LANGUAGES.find(l => l.code === lang)?.flag}
{showLangMenu && (<>
setShowLangMenu(false)}>
{LANGUAGES.map(l => ( { setLang(l.code); setShowLangMenu(false); }} className="w-full py-2 hover:bg-gray-700 text-lg flex justify-center active:bg-gray-600">{l.flag} ))}
>)}
setShowCityModal(true)} className="flex items-center gap-1 bg-[#1f2937] px-2 py-1.5 rounded-full text-xs font-bold hover:bg-gray-700 transition-colors border border-gray-700 text-gray-200 active:scale-95"> {t(`city_${selectedCity}`)}
{activeTab === 'home' && renderHome()}
{activeTab === 'coupons' && renderCouponBox()}
{activeTab === 'community' && renderCommunity()}
{activeTab === 'mypage' && renderMyPage()}
{/* Nav & Floating Buttons (Same as before) */}
setActiveTab('home')} className={`flex flex-col items-center gap-1 p-2 min-w-[50px] ${activeTab === 'home' ? 'text-pink-500' : 'text-gray-500'}`}>
{t('home')}
setActiveTab('coupons')} className={`flex flex-col items-center gap-1 p-2 min-w-[50px] ${activeTab === 'coupons' ? 'text-pink-500' : 'text-gray-500'}`}>
{t('coupon')}
setActiveTab('community')} className={`flex flex-col items-center gap-1 p-2 min-w-[50px] ${activeTab === 'community' ? 'text-pink-500' : 'text-gray-500'}`}>
{t('community')}
setActiveTab('mypage')} className={`flex flex-col items-center gap-1 p-2 min-w-[50px] ${activeTab === 'mypage' ? 'text-pink-500' : 'text-gray-500'}`}>
{t('mypage')}
{activeTab === 'community' && (
)}
{ if(!user || user.isAnonymous) { alert(t('login_needed')); return; } setShowChat(!showChat); }} className="bg-[#1f2937] border border-pink-500/50 text-pink-500 w-12 h-12 rounded-full flex flex-col items-center justify-center shadow-lg shadow-pink-500/20 hover:scale-105 transition-transform active:scale-90">
{showChat ? : }
{t('traveler_talk')}
{showChat && (
{t(`city_${selectedCity}`)} Talk 124명 접속 {chatMessages.map(msg => (
))}
setChatInput(e.target.value)} onKeyPress={e => e.key === 'Enter' && handleSendChat()} />
)}
{/* Existing Modals */}
setSelectedCoupon(null)} title={t('boss_check_title')} bgColor="bg-white">
{selectedCoupon && (
사용처 {selectedCoupon.name}
제공 혜택
{typeof selectedCoupon.coupon === 'string' ? selectedCoupon.coupon : (selectedCoupon.coupon[lang] || selectedCoupon.coupon['ko'])}
{typeof selectedCoupon.couponDesc === 'string' ? selectedCoupon.couponDesc : (selectedCoupon.couponDesc[lang] || selectedCoupon.couponDesc['ko'])}
남은 시간: 23:59:45
{t('boss_check')}
{t('coupon_validity')}
)}
{/* Updated Signup Modal */}
setShowAuthModal(false)} title={authMode === 'login' ? t('login') : t('signup')}>
setAuthMode('login')} className={`flex-1 py-2.5 text-sm font-bold rounded-lg transition-all ${authMode === 'login' ? 'bg-[#1f2937] text-white' : 'text-gray-400'}`}>{t('login')}
setAuthMode('signup')} className={`flex-1 py-2.5 text-sm font-bold rounded-lg transition-all ${authMode === 'signup' ? 'bg-[#1f2937] text-white' : 'text-gray-400'}`}>{t('signup')}
{authMode === 'login' ? (
) : (
)}
{/* ... Other Modals (City, Congrats, Inquiry, Write, Lesson) ... */}
setShowCityModal(false)} title={t('city_select')}>
{CITIES.map(city => ( handleCitySelect(city.id)} className={`p-5 rounded-xl flex flex-col items-center gap-2 border ${selectedCity === city.id ? 'bg-pink-500/10 border-pink-500' : 'bg-[#1f2937] border-gray-700'}`}>{city.icon} {t(`city_${city.id}`)} ))}
setSpinResult(null)} title={t('congrats')}>
{spinResult && (
{spinResult.name}
{typeof spinResult.coupon === 'string' ? spinResult.coupon : (spinResult.coupon[lang] || spinResult.coupon['ko'])}
{ handleSaveCoupon(spinResult.id); setSpinResult(null); }} className="w-full bg-gradient-to-r from-pink-600 to-purple-600 text-white font-bold py-4 rounded-xl shadow-lg text-lg active:scale-98">{t('save_coupon')}
)}
setShowInquiryModal(false)} title={t('boss_only')}>
{/* Existing Inquiry Form */}
{/* Report Modal */}
setShowReportModal(false)} title={t('report_title')}>
신고 대상: {reportTarget?.title || '게시글'}
{/* Business Detail Modal with Grab Link */}
setSelectedBusiness(null)} hideClose>
{selectedBusiness && (
setSelectedBusiness(null)} className="absolute top-3 right-3 p-2 rounded-full bg-black/50 text-white z-10 backdrop-blur-sm active:scale-90">
{t(`cat_${selectedBusiness.category}`)}
{selectedBusiness.name}
{/* Removed Star Rating */}
이달의 라인업 업체입니다.
쿠폰은 룰렛을 통해서만 획득 가능합니다!
{typeof selectedBusiness.desc === 'string' ? selectedBusiness.desc : (selectedBusiness.desc[lang] || selectedBusiness.desc['ko'])}
{t('address_label')}
{selectedBusiness.address || '주소 정보 없음'}
{t('phone_label')}
{selectedBusiness.phone || '전화번호 없음'}
{t('business_hours')}
{selectedBusiness.hours}
window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedBusiness.address || selectedBusiness.name + ' ' + selectedBusiness.city)}`, '_blank')}
className="flex-1 py-4 rounded-xl bg-[#111827] text-gray-300 font-bold text-base flex items-center justify-center gap-2 hover:bg-black border border-gray-700 active:scale-98"
>
{t('map_view')}
{t('grab_go')}
)}
setShowSlotShop(false)} title="슬롯 확장 (결제)">
{SLOT_OPTIONS.map((option, i) => (
handleBuySlot(option)} className="w-full bg-gray-800 hover:bg-gray-700 p-4 rounded-xl flex justify-between items-center border border-gray-600 transition-colors group active:scale-98">
{option.label}
최대 15개까지 보유 가능
${option.price}
))}
결제 기능은 추후 PayPal/Firebase와 연동될 예정입니다. 현재는 테스트 모드로 동작합니다.
{/* Other modals like Lesson Write/Apply are kept but omitted in this snippet for brevity if unchanged */}
setShowLessonWriteModal(false)} title={t('lesson_write')}>
setShowLessonApplyModal(false)} title={t('lesson_apply_title')}>
setShowApplicantListModal(false)} title={t('lesson_applicants_view')}>
현재 신청자 수
{applicantCount}명
{lessonApplicants.length > 0 ? ( lessonApplicants.map(applicant => (
{applicant.name} {LESSON_LOCATIONS.find(l => l.id === applicant.region)?.name || applicant.region}
연락처 {applicant.phone}
메신저 ID {applicant.messengerId}
{applicant.timestamp ? new Date(applicant.timestamp.seconds * 1000).toLocaleString() : ''}
)) ) : (
아직 신청자가 없습니다.
)}
);
}