feat: Montvale 실상품37/실이미지 + 프리미엄 플로럴 프론트 + 결제수단/SNS + loyalty/event + 게이트웨이 어댑터 + 관리자 토큰분리
469
backend/src/main/resources/static/assets/index-BpKe6Yxq.js
Normal file
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 61 KiB |
BIN
backend/src/main/resources/static/img/products/fruit-basket.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 62 KiB |
BIN
backend/src/main/resources/static/img/products/green-grace.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
backend/src/main/resources/static/img/products/halcyon-heart.jpg
Normal file
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 58 KiB |
BIN
backend/src/main/resources/static/img/products/joyful-thanks.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
backend/src/main/resources/static/img/products/lavender-lust.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
backend/src/main/resources/static/img/products/love-brett.gif
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 72 KiB |
@ -12,8 +12,8 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Playfair+Display:wght@500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script type="module" crossorigin src="/assets/index-DGrOz8iv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C47e4sku.css">
|
||||
<script type="module" crossorigin src="/assets/index-BpKe6Yxq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CrOT_Ttv.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -30,7 +30,7 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${isActive ? 'bg-card text-brand border-r-2 border-brand' : 'text-slate-300 hover:bg-card/60'}`
|
||||
|
||||
export default function AdminLayout() {
|
||||
const token = localStorage.getItem('mall_token')
|
||||
const token = localStorage.getItem('mall_admin_token')
|
||||
const [role, setRole] = useState(() => localStorage.getItem('mall_role') || '')
|
||||
const [who, setWho] = useState(() => localStorage.getItem('mall_admin_user') || '')
|
||||
const nav = useNavigate()
|
||||
@ -56,7 +56,7 @@ export default function AdminLayout() {
|
||||
const visibleAdmin = adminLinks.filter(l => l.roles.includes(role))
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('mall_token'); localStorage.removeItem('mall_role'); localStorage.removeItem('mall_admin_user')
|
||||
localStorage.removeItem('mall_admin_token'); localStorage.removeItem('mall_role'); localStorage.removeItem('mall_admin_user')
|
||||
nav('/admin/login')
|
||||
}
|
||||
|
||||
|
||||
@ -20,11 +20,12 @@ export default function AdminLogin() {
|
||||
const res = await login(username, password)
|
||||
const token = res.data?.data?.token
|
||||
if (!token) throw new Error('no token')
|
||||
localStorage.setItem('mall_token', token)
|
||||
// 관리자 토큰은 고객 세션(mall_token)과 분리된 키에 저장 — 인터셉터가 /admin 영역에서 사용
|
||||
localStorage.setItem('mall_admin_token', token)
|
||||
const me: any = await getMe().catch(() => null)
|
||||
if (me?.role) localStorage.setItem('mall_role', me.role)
|
||||
if (me?.username) localStorage.setItem('mall_admin_user', me.username)
|
||||
if (me?.role === 'USER') { setErr('관리자 권한이 없습니다.'); localStorage.removeItem('mall_token'); return }
|
||||
if (me?.role === 'USER') { setErr('관리자 권한이 없습니다.'); localStorage.removeItem('mall_admin_token'); return }
|
||||
nav('/admin/dashboard')
|
||||
} catch {
|
||||
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.')
|
||||
|
||||
@ -3,16 +3,22 @@ import axios from 'axios'
|
||||
// 단일 jar 운영 시 same-origin, dev 시 proxy(/api → 8011). baseURL 비워 same-origin 사용.
|
||||
const api = axios.create({ baseURL: '' })
|
||||
|
||||
// 관리자 영역(/admin)은 관리자 토큰(mall_admin_token)을, 고객 영역은 고객 토큰(mall_token)을 사용.
|
||||
// 두 세션이 같은 키를 공유하면 한쪽 로그인이 다른 쪽 세션을 덮어쓰는 충돌이 발생하므로 분리한다.
|
||||
const isAdminArea = () => location.pathname.startsWith('/admin')
|
||||
const tokenKey = () => (isAdminArea() ? 'mall_admin_token' : 'mall_token')
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('mall_token')
|
||||
const token = localStorage.getItem(tokenKey())
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
api.interceptors.response.use(res => res, err => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('mall_token')
|
||||
// 현재 영역의 토큰만 제거 (반대편 세션 보존)
|
||||
localStorage.removeItem(tokenKey())
|
||||
// 관리자 영역에서만 강제 로그인 리다이렉트
|
||||
if (location.pathname.startsWith('/admin') && location.pathname !== '/admin/login') {
|
||||
if (isAdminArea() && location.pathname !== '/admin/login') {
|
||||
location.href = '/admin/login'
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ import {
|
||||
getLoyaltyMe, checkout, createPayment,
|
||||
} from '../api/client'
|
||||
import { money, useShop } from '../store/shop'
|
||||
import { PaymentSection, PayMethod } from './components/payments'
|
||||
import { BRAND } from './brand'
|
||||
|
||||
function nextDays(n: number) {
|
||||
const out: string[] = []
|
||||
@ -29,6 +31,8 @@ export default function Checkout() {
|
||||
const [cardMessage, setCardMessage] = useState('')
|
||||
const [memo, setMemo] = useState('')
|
||||
const [usePointsAmt, setUsePointsAmt] = useState(0)
|
||||
const [payMethod, setPayMethod] = useState<PayMethod>('CARD')
|
||||
const [card, setCard] = useState<{ last4: string; expiry: string; complete: boolean }>({ last4: '', expiry: '', complete: false })
|
||||
const [addrCheck, setAddrCheck] = useState<any>(null)
|
||||
const [tax, setTax] = useState<any>(null)
|
||||
const [placing, setPlacing] = useState(false)
|
||||
@ -77,6 +81,7 @@ export default function Checkout() {
|
||||
if (!items.length) { setErr('장바구니가 비어 있습니다.'); return }
|
||||
if (!date || !slot) { setErr('배송/픽업 날짜와 시간대를 선택하세요.'); return }
|
||||
if (fulfillment === 'DELIVERY' && (!address || !receiverName)) { setErr('받는 분과 주소를 입력하세요.'); return }
|
||||
if (payMethod === 'CARD' && !card.complete) { setErr('카드 정보를 입력하세요.'); return }
|
||||
setPlacing(true)
|
||||
try {
|
||||
const order = await checkout({
|
||||
@ -87,8 +92,11 @@ export default function Checkout() {
|
||||
discountAmount: Math.round((tierDiscount + usePointsAmt) * 100) / 100,
|
||||
taxAmount, surgeAmount: Math.round(surge * 100) / 100,
|
||||
})
|
||||
// 결제 게이트웨이 경유 (mock) — 카드정보 미보관, 백엔드 PaymentGateway 처리
|
||||
await createPayment({ orderId: order.id, amount: grand, method: 'CARD', usePoints: usePointsAmt }).catch(() => {})
|
||||
// 결제 게이트웨이 경유 (mock/Stripe) — 카드 전체번호 미보관, 마지막 4자리만 참조용 전달
|
||||
await createPayment({
|
||||
orderId: order.id, amount: grand, method: payMethod, usePoints: usePointsAmt,
|
||||
cardLast4: payMethod === 'CARD' ? card.last4 : '',
|
||||
}).catch(() => {})
|
||||
setCartCount(0)
|
||||
setDone(order)
|
||||
} catch (e: any) {
|
||||
@ -198,6 +206,18 @@ export default function Checkout() {
|
||||
<textarea value={cardMessage} onChange={e => setCardMessage(e.target.value)} rows={2} maxLength={200} placeholder="받는 분께 전할 메시지" className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
|
||||
<input value={memo} onChange={e => setMemo(e.target.value)} placeholder="배송 요청사항 (선택)" className="w-full px-3 py-2 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
|
||||
</section>
|
||||
|
||||
{/* 결제수단 (Montvale: Visa·Mastercard·Amex·Discover·Apple Pay·Google Pay) */}
|
||||
<section className="bg-white rounded-2xl border border-blush-100/60 p-5">
|
||||
<div className="text-sm font-semibold mb-3">결제수단 (Payment)</div>
|
||||
<PaymentSection
|
||||
method={payMethod}
|
||||
onMethod={setPayMethod}
|
||||
onCardChange={setCard}
|
||||
cards={BRAND.cards}
|
||||
wallets={BRAND.wallets}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 요약 */}
|
||||
|
||||
@ -6,6 +6,7 @@ import { Flower2, Star, Heart, Sparkles, ShoppingCart, Truck, ShieldCheck } from
|
||||
import { getProduct, getReviews, getReviewStats, aiReviewSummary, addToCart, addWishlist } from '../api/client'
|
||||
import { money, useShop } from '../store/shop'
|
||||
import { Reveal, Pressable } from './components/motion'
|
||||
import { ShareButtons } from './components/social'
|
||||
import { Product, ProductOption } from '../types'
|
||||
|
||||
export default function ProductDetail() {
|
||||
@ -159,6 +160,15 @@ export default function ProductDetail() {
|
||||
<span className="flex items-center gap-1.5"><Truck size={14} /> Same-day local delivery</span>
|
||||
<span className="flex items-center gap-1.5"><ShieldCheck size={14} /> 100% satisfaction</span>
|
||||
</div>
|
||||
|
||||
{/* SNS 공유 (Instagram / Facebook / Pinterest) */}
|
||||
<div className="mt-4 pt-4 border-t border-blush-100/60">
|
||||
<ShareButtons
|
||||
url={typeof window !== 'undefined' ? window.location.href : ''}
|
||||
title={p.name}
|
||||
image={p.thumbnail || ''}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
|
||||
@ -3,11 +3,13 @@ import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
||||
import {
|
||||
Flower2, MapPin, ShoppingCart, Heart, User, Search, Star, Phone,
|
||||
Instagram, Facebook, Clock, ChevronRight,
|
||||
Clock, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { useShop } from '../store/shop'
|
||||
import { getCart } from '../api/client'
|
||||
import { BRAND } from './brand'
|
||||
import { SocialLinks } from './components/social'
|
||||
import { PaymentBadges } from './components/payments'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/home', label: 'Home' },
|
||||
@ -128,10 +130,7 @@ export default function ShopLayout() {
|
||||
<div className="font-serif text-xl font-bold text-white mb-1">Montvale Florist</div>
|
||||
<p className="font-display text-[13px] tracking-[0.3em] uppercase text-sage-300 mb-3">Since {BRAND.founded}</p>
|
||||
<p className="text-[13px] leading-relaxed text-cream/70">{BRAND.tagline}</p>
|
||||
<div className="flex gap-3 mt-4">
|
||||
<a href={BRAND.social.instagram} target="_blank" rel="noreferrer" className="hover:text-white" aria-label="Instagram"><Instagram size={18} /></a>
|
||||
<a href={BRAND.social.facebook} target="_blank" rel="noreferrer" className="hover:text-white" aria-label="Facebook"><Facebook size={18} /></a>
|
||||
</div>
|
||||
<SocialLinks size={18} className="mt-4 text-cream/80" iconClass="hover:text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -156,11 +155,8 @@ export default function ShopLayout() {
|
||||
<Link to="/orders" className="flex items-center gap-1 text-[13px] text-cream/75 hover:text-white mb-1.5"><ChevronRight size={13} /> Order Status</Link>
|
||||
<Link to="/subscription" className="flex items-center gap-1 text-[13px] text-cream/75 hover:text-white mb-1.5"><ChevronRight size={13} /> Subscriptions</Link>
|
||||
<Link to="/app" className="flex items-center gap-1 text-[13px] text-gold hover:text-white mb-3 font-medium"><ChevronRight size={13} /> Get the App (QR)</Link>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{BRAND.payments.map(p => (
|
||||
<span key={p} className="text-[10px] bg-cream/10 text-cream/70 rounded px-2 py-1">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[11px] text-cream/50 mb-1.5">We accept</div>
|
||||
<PaymentBadges items={BRAND.payments} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-cream/10">
|
||||
|
||||
@ -32,9 +32,13 @@ export const BRAND = {
|
||||
instagramHandle: '@themontvaleflorist',
|
||||
facebook: 'https://facebook.com/montvaleflorist1',
|
||||
pinterest: 'https://pinterest.com/montvaleflorist',
|
||||
google: 'https://www.google.com/search?q=Montvale+Florist',
|
||||
yelp: 'https://yelp.com/biz/montvale-florist-montvale-3',
|
||||
},
|
||||
// Montvale 결제수단 6종 — 카드 브랜드 4 + 디지털 월렛 2. 백엔드 PaymentGateway 어댑터로 매핑.
|
||||
payments: ['Visa', 'Mastercard', 'Amex', 'Discover', 'Apple Pay', 'Google Pay'],
|
||||
wallets: ['Apple Pay', 'Google Pay'],
|
||||
cards: ['Visa', 'Mastercard', 'Amex', 'Discover'],
|
||||
policies: ['Terms of Service', 'Privacy Policy', 'Accessibility Statement', 'Delivery Policy'],
|
||||
about:
|
||||
'Montvale Florist is your go-to local florist, delivering not just flowers, but joy, comfort, and memories. An independent, community-focused florist dedicated to craftsmanship and personal service since 2010.',
|
||||
|
||||
221
frontend/src/shop/components/payments.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
// Montvale 결제수단 UI (montvalefloristnj.com 그대로)
|
||||
// 카드 브랜드 4종(Visa·Mastercard·Amex·Discover) + 디지털 월렛 2종(Apple Pay·Google Pay).
|
||||
// 카드 전체번호는 화면 저장/표시 금지 — 마스킹. 실제 결제는 백엔드 PaymentGateway 어댑터 경유.
|
||||
import { useState, ReactElement } from 'react'
|
||||
import { CreditCard, Lock } from 'lucide-react'
|
||||
|
||||
/* ── 카드 브랜드 배지 (인라인 SVG, 외부 의존성 없음) ── */
|
||||
function VisaBadge() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 32" className="h-7 w-auto" role="img" aria-label="Visa">
|
||||
<rect width="48" height="32" rx="4" fill="#fff" stroke="#e7e2dc" />
|
||||
<text x="24" y="21" textAnchor="middle" fontFamily="Georgia, serif" fontWeight="700" fontStyle="italic" fontSize="13" fill="#1a1f71">VISA</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function MastercardBadge() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 32" className="h-7 w-auto" role="img" aria-label="Mastercard">
|
||||
<rect width="48" height="32" rx="4" fill="#fff" stroke="#e7e2dc" />
|
||||
<circle cx="20" cy="16" r="8" fill="#eb001b" />
|
||||
<circle cx="28" cy="16" r="8" fill="#f79e1b" fillOpacity="0.85" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function AmexBadge() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 32" className="h-7 w-auto" role="img" aria-label="American Express">
|
||||
<rect width="48" height="32" rx="4" fill="#2e77bb" />
|
||||
<text x="24" y="20" textAnchor="middle" fontFamily="Arial, sans-serif" fontWeight="700" fontSize="9" fill="#fff">AMEX</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function DiscoverBadge() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 32" className="h-7 w-auto" role="img" aria-label="Discover">
|
||||
<rect width="48" height="32" rx="4" fill="#fff" stroke="#e7e2dc" />
|
||||
<circle cx="36" cy="22" r="9" fill="#f68121" />
|
||||
<text x="22" y="19" textAnchor="middle" fontFamily="Arial, sans-serif" fontWeight="700" fontSize="7" fill="#231f20">DISCOVER</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function ApplePayBadge() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 32" className="h-7 w-auto" role="img" aria-label="Apple Pay">
|
||||
<rect width="48" height="32" rx="4" fill="#000" />
|
||||
<text x="24" y="20" textAnchor="middle" fontFamily="-apple-system, Helvetica, sans-serif" fontWeight="600" fontSize="9" fill="#fff"> Pay</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function GooglePayBadge() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 32" className="h-7 w-auto" role="img" aria-label="Google Pay">
|
||||
<rect width="48" height="32" rx="4" fill="#fff" stroke="#e7e2dc" />
|
||||
<text x="24" y="20" textAnchor="middle" fontFamily="Arial, sans-serif" fontWeight="700" fontSize="9">
|
||||
<tspan fill="#4285f4">G</tspan><tspan fill="#ea4335">o</tspan><tspan fill="#fbbc05">o</tspan><tspan fill="#4285f4">g</tspan><tspan fill="#34a853">l</tspan><tspan fill="#ea4335">e</tspan>
|
||||
<tspan fill="#5f6368"> Pay</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const BADGES: Record<string, () => ReactElement> = {
|
||||
Visa: VisaBadge,
|
||||
Mastercard: MastercardBadge,
|
||||
Amex: AmexBadge,
|
||||
Discover: DiscoverBadge,
|
||||
'Apple Pay': ApplePayBadge,
|
||||
'Google Pay': GooglePayBadge,
|
||||
}
|
||||
|
||||
// 푸터/About 등에 쓰는 결제수단 배지 줄 (6종 그대로)
|
||||
export function PaymentBadges({ items, className = '' }: { items: string[]; className?: string }) {
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-1.5 ${className}`}>
|
||||
{items.map(p => {
|
||||
const B = BADGES[p]
|
||||
return B ? <B key={p} /> : <span key={p} className="text-[10px] bg-cream/10 rounded px-2 py-1">{p}</span>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 카드 번호 마스킹: 마지막 4자리만 노출 ── */
|
||||
export function maskCard(num: string): string {
|
||||
const digits = num.replace(/\D/g, '')
|
||||
if (digits.length < 4) return '•••• •••• •••• ••••'
|
||||
return `•••• •••• •••• ${digits.slice(-4)}`
|
||||
}
|
||||
function formatCardInput(num: string): string {
|
||||
return num.replace(/\D/g, '').slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
|
||||
}
|
||||
|
||||
export type WalletMethod = 'APPLE_PAY' | 'GOOGLE_PAY'
|
||||
export type PayMethod = 'CARD' | WalletMethod
|
||||
|
||||
/* ── 결제 섹션: 카드 폼(Stripe Elements 스타일 모의) + 디지털 월렛 버튼 ──
|
||||
* 카드 전체번호는 컴포넌트 외부로 전달하지 않으며(부모는 마지막4·만료만 수신), 화면에는 마스킹만 표시.
|
||||
* 운영 시 Stripe Elements로 교체 가능 — 동일한 onChange 계약 유지. */
|
||||
export function PaymentSection({
|
||||
method,
|
||||
onMethod,
|
||||
onCardChange,
|
||||
cards = ['Visa', 'Mastercard', 'Amex', 'Discover'],
|
||||
wallets = ['Apple Pay', 'Google Pay'],
|
||||
}: {
|
||||
method: PayMethod
|
||||
onMethod: (m: PayMethod) => void
|
||||
onCardChange?: (info: { last4: string; expiry: string; complete: boolean }) => void
|
||||
cards?: string[]
|
||||
wallets?: string[]
|
||||
}) {
|
||||
const [number, setNumber] = useState('')
|
||||
const [expiry, setExpiry] = useState('')
|
||||
const [cvc, setCvc] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
const digits = number.replace(/\D/g, '')
|
||||
const emit = (n = number, e = expiry, c = cvc) => {
|
||||
const d = n.replace(/\D/g, '')
|
||||
const complete = d.length >= 15 && /^\d{2}\/\d{2}$/.test(e) && c.replace(/\D/g, '').length >= 3
|
||||
onCardChange?.({ last4: d.slice(-4), expiry: e, complete })
|
||||
}
|
||||
|
||||
const walletKey = (w: string): WalletMethod => (w === 'Apple Pay' ? 'APPLE_PAY' : 'GOOGLE_PAY')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 결제수단 선택 (카드 + 디지털 월렛) */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button" onClick={() => onMethod('CARD')}
|
||||
className={`flex items-center justify-center gap-1.5 py-2.5 rounded-xl border text-sm ${method === 'CARD' ? 'border-bloom bg-petal text-bloom2 font-semibold' : 'border-blush-100 text-[#6b5258]'}`}>
|
||||
<CreditCard size={16} /> Card
|
||||
</button>
|
||||
{wallets.map(w => {
|
||||
const key = walletKey(w)
|
||||
const B = BADGES[w]
|
||||
return (
|
||||
<button
|
||||
key={w} type="button" onClick={() => onMethod(key)}
|
||||
className={`flex items-center justify-center py-2 rounded-xl border ${method === key ? 'border-bloom bg-petal' : 'border-blush-100'}`}
|
||||
aria-label={w}>
|
||||
{B ? <B /> : <span className="text-sm">{w}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 허용 카드 브랜드 배지 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-gray-400">Accepted:</span>
|
||||
<PaymentBadges items={cards} />
|
||||
</div>
|
||||
|
||||
{method === 'CARD' ? (
|
||||
// Stripe Elements 스타일 모의 폼 — 전체번호 미저장/마스킹
|
||||
<div className="rounded-2xl border border-blush-100 p-4 space-y-3 bg-white">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Card number</label>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-xl border border-blush-100 focus-within:border-bloom">
|
||||
<CreditCard size={16} className="text-blush-400 shrink-0" />
|
||||
<input
|
||||
inputMode="numeric" autoComplete="cc-number"
|
||||
value={focused ? formatCardInput(number) : (digits ? maskCard(number) : '')}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onChange={e => { const v = e.target.value; setNumber(v); emit(v) }}
|
||||
placeholder="1234 1234 1234 1234"
|
||||
className="flex-1 bg-transparent text-sm outline-none tracking-wider" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Expiry (MM/YY)</label>
|
||||
<input
|
||||
inputMode="numeric" autoComplete="cc-exp" value={expiry}
|
||||
onChange={e => {
|
||||
let v = e.target.value.replace(/\D/g, '').slice(0, 4)
|
||||
if (v.length >= 3) v = v.slice(0, 2) + '/' + v.slice(2)
|
||||
setExpiry(v); emit(number, v)
|
||||
}}
|
||||
placeholder="MM/YY"
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">CVC</label>
|
||||
<input
|
||||
inputMode="numeric" autoComplete="cc-csc" value={cvc}
|
||||
onChange={e => { const v = e.target.value.replace(/\D/g, '').slice(0, 4); setCvc(v); emit(number, expiry, v) }}
|
||||
placeholder="•••" type="password"
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Name on card</label>
|
||||
<input
|
||||
autoComplete="cc-name" value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder="Full name"
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-blush-100 text-sm outline-none focus:border-bloom" />
|
||||
</div>
|
||||
<p className="flex items-center gap-1 text-[11px] text-gray-400">
|
||||
<Lock size={11} /> Card number is masked and never stored on this device. Processed via GUARDiA PaymentGateway.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
// 디지털 월렛 — 버튼 표시 (미설정 시 백엔드 mock 처리)
|
||||
<div className="rounded-2xl border border-blush-100 p-4 bg-white">
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full py-3 rounded-xl font-semibold flex items-center justify-center gap-2 ${method === 'APPLE_PAY' ? 'bg-black text-white' : 'bg-white border border-edge text-[#3c4043]'}`}>
|
||||
{method === 'APPLE_PAY' ? ' Pay' : 'G Pay'}
|
||||
</button>
|
||||
<p className="flex items-center gap-1 text-[11px] text-gray-400 mt-2">
|
||||
<Lock size={11} /> {method === 'APPLE_PAY' ? 'Apple Pay' : 'Google Pay'} via secure wallet. If unconfigured, processed as mock at checkout.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
frontend/src/shop/components/social.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
// Montvale SNS 연동 (montvalefloristnj.com 그대로)
|
||||
// Instagram @themontvaleflorist · Facebook montvaleflorist1 · Pinterest · Google Business · Yelp
|
||||
// 실제 링크는 brand.ts (montvale_branding.md 정합).
|
||||
import { ReactElement } from 'react'
|
||||
import { Instagram, Facebook } from 'lucide-react'
|
||||
import { BRAND } from '../brand'
|
||||
|
||||
/* ── Pinterest / Google / Yelp 인라인 SVG (lucide 미수록) ── */
|
||||
function PinterestIcon({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12c0 4.24 2.64 7.85 6.36 9.31-.09-.79-.17-2 .03-2.86.18-.78 1.17-4.97 1.17-4.97s-.3-.6-.3-1.48c0-1.39.81-2.43 1.81-2.43.85 0 1.27.64 1.27 1.41 0 .86-.55 2.14-.83 3.33-.24 1 .5 1.81 1.48 1.81 1.78 0 3.14-1.88 3.14-4.58 0-2.4-1.72-4.07-4.19-4.07-2.85 0-4.52 2.14-4.52 4.35 0 .86.33 1.78.74 2.28.08.1.09.19.07.29l-.27 1.13c-.04.18-.14.22-.33.13-1.25-.58-2.03-2.4-2.03-3.87 0-3.15 2.29-6.04 6.6-6.04 3.46 0 6.16 2.47 6.16 5.77 0 3.44-2.17 6.21-5.18 6.21-1.01 0-1.97-.53-2.29-1.15l-.62 2.37c-.23.86-.83 1.94-1.24 2.6.94.29 1.92.44 2.95.44 5.52 0 10-4.48 10-10S17.52 2 12 2z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function GoogleIcon({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.1a6.6 6.6 0 0 1 0-4.2V7.06H2.18a11 11 0 0 0 0 9.88l3.66-2.84z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.06l3.66 2.84C6.71 7.3 9.14 5.38 12 5.38z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function YelpIcon({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
|
||||
<path d="M12.27 13.3l4.45-2.16c.51-.25.66-.92.31-1.36-1.13-1.44-2.7-2.49-4.49-2.99-.55-.15-1.08.27-1.08.84l-.02 5.04c0 .65.72 1.06 1.32.77zM12.6 15.34l4.46 2.13c.51.25 1.13-.07 1.21-.63.25-1.8-.06-3.66-.92-5.31-.27-.51-.96-.6-1.36-.18l-3.5 3.42c-.45.45-.31 1.18.11 1.4v-.84zm-2.59.4l-3.45-3.4c-.41-.4-1.09-.32-1.37.18-.86 1.64-1.18 3.49-.95 5.29.07.56.69.89 1.21.64l4.45-2.12c.6-.29.74-1.02.06-1.43zm.08 2.36l-.02 4.95c0 .57.53.99 1.08.84 1.78-.49 3.34-1.53 4.48-2.96.35-.44.2-1.11-.31-1.36l-4.45-2.15c-.6-.29-1.31.13-1.31.78l.84.01zm-.45-7.1L5.66 7.06c-.43-.7-1.46-.56-1.69.23-.13.45-.22.92-.27 1.4-.16 1.55.05 3.12.61 4.56.21.53.91.62 1.27.13l3.95-5.32c.32-.43.06-1.05-.5-1.16l.39.56z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type SocialKey = 'instagram' | 'facebook' | 'pinterest' | 'google' | 'yelp'
|
||||
const ICONS: Record<SocialKey, (p: { size?: number }) => ReactElement> = {
|
||||
instagram: ({ size }) => <Instagram size={size} />,
|
||||
facebook: ({ size }) => <Facebook size={size} />,
|
||||
pinterest: PinterestIcon,
|
||||
google: GoogleIcon,
|
||||
yelp: YelpIcon,
|
||||
}
|
||||
const LABELS: Record<SocialKey, string> = {
|
||||
instagram: 'Instagram', facebook: 'Facebook', pinterest: 'Pinterest', google: 'Google Business', yelp: 'Yelp',
|
||||
}
|
||||
const ORDER: SocialKey[] = ['instagram', 'facebook', 'pinterest', 'google', 'yelp']
|
||||
|
||||
// 헤더/푸터/About 소셜 아이콘 줄 (5종 그대로)
|
||||
export function SocialLinks({ size = 18, className = '', iconClass = '' }: { size?: number; className?: string; iconClass?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
{ORDER.map(k => {
|
||||
const href = (BRAND.social as any)[k] as string | undefined
|
||||
if (!href) return null
|
||||
const Icon = ICONS[k]
|
||||
return (
|
||||
<a key={k} href={href} target="_blank" rel="noreferrer" aria-label={LABELS[k]}
|
||||
className={`transition-colors ${iconClass}`}>
|
||||
<Icon size={size} />
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 상품 공유 버튼: Instagram / Facebook / Pinterest share ──
|
||||
* Facebook·Pinterest는 표준 share URL. Instagram은 직접 share URL이 없어 프로필로 연결. */
|
||||
export function ShareButtons({ url, title, image, className = '' }: { url: string; title?: string; image?: string; className?: string }) {
|
||||
const u = encodeURIComponent(url)
|
||||
const t = encodeURIComponent(title || BRAND.name)
|
||||
const img = encodeURIComponent(image || '')
|
||||
const fb = `https://www.facebook.com/sharer/sharer.php?u=${u}`
|
||||
const pin = `https://pinterest.com/pin/create/button/?url=${u}&media=${img}&description=${t}`
|
||||
const ig = BRAND.social.instagram
|
||||
const open = (href: string) => window.open(href, '_blank', 'noopener,width=640,height=600')
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<span className="text-xs text-[#a08a90]">Share:</span>
|
||||
<button type="button" onClick={() => open(ig)} aria-label="Share on Instagram"
|
||||
className="p-2 rounded-full border border-blush-100 text-blush-500 hover:bg-blush-50 transition-colors">
|
||||
<Instagram size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => open(fb)} aria-label="Share on Facebook"
|
||||
className="p-2 rounded-full border border-blush-100 text-blush-500 hover:bg-blush-50 transition-colors">
|
||||
<Facebook size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => open(pin)} aria-label="Share on Pinterest"
|
||||
className="p-2 rounded-full border border-blush-100 text-blush-500 hover:bg-blush-50 transition-colors">
|
||||
<PinterestIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||