feat: Montvale 실상품37/실이미지 + 프리미엄 플로럴 프론트 + 결제수단/SNS + loyalty/event + 게이트웨이 어댑터 + 관리자 토큰분리

This commit is contained in:
GUARDiA 2026-06-13 21:00:52 +09:00
parent b519efe629
commit bc4afe7c2d
51 changed files with 842 additions and 481 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -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>

View File

@ -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')
}

View File

@ -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('로그인 실패 — 아이디/비밀번호를 확인하세요.')

View File

@ -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'
}
}

View File

@ -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>
{/* 요약 */}

View File

@ -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>

View File

@ -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">

View File

@ -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.',

View 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>
)
}

View 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>
)
}