feat(home): Montvale 3종 회전 히어로 + favicon

This commit is contained in:
GUARDiA 2026-06-13 22:32:40 +09:00
parent 3bf54be07e
commit 18e674c8da
19 changed files with 270 additions and 161 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

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: 641 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fdfaf5" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<title>Montvale Florist — 100% Florist-Designed and Hand-Delivered</title>
<meta name="description" content="Montvale Florist — your local independent florist in Montvale, NJ. Delivering not just flowers, but joy, comfort, and memories since 2010." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
@ -12,8 +15,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-C1zhKYFu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CrOT_Ttv.css">
<script type="module" crossorigin src="/assets/index-BIFg_hhJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CixfEA04.css">
</head>
<body>
<div id="root"></div>

View File

@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fdfaf5" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<title>Montvale Florist — 100% Florist-Designed and Hand-Delivered</title>
<meta name="description" content="Montvale Florist — your local independent florist in Montvale, NJ. Delivering not just flowers, but joy, comfort, and memories since 2010." />
<link rel="preconnect" href="https://fonts.googleapis.com" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 B

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -1,71 +1,95 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { motion, useScroll, useTransform, useReducedMotion } from 'framer-motion'
import { useRef } from 'react'
import { Sparkles, Flower2, Leaf, Star, ArrowRight, Truck, HandHeart, ShieldCheck } from 'lucide-react'
import { getEventBanners, aiRecommend, getProducts } from '../api/client'
import { motion, AnimatePresence, useScroll, useTransform, useReducedMotion } from 'framer-motion'
import { useRef, useState, useEffect, useCallback } from 'react'
import { Sparkles, Flower2, Leaf, Star, ArrowRight, Truck, HandHeart, ShieldCheck, ChevronLeft, ChevronRight } from 'lucide-react'
import { aiRecommend, getProducts } from '../api/client'
import ProductCard from './components/ProductCard'
import { Reveal, StaggerGrid, StaggerItem, PetalLayer, Pressable } from './components/motion'
import { useShop } from '../store/shop'
import { BRAND } from './brand'
import { BRAND, HERO_SLIDES } from './brand'
const PROMISE_ICON = [HandHeart, Flower2, ShieldCheck]
const SLIDE_INTERVAL = 6000 // 6초 자동 전환
export default function Home() {
function HeroCarousel() {
const { storeName } = useShop()
const reduce = useReducedMotion()
const heroRef = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({ target: heroRef, offset: ['start start', 'end start'] })
const heroY = useTransform(scrollYProgress, [0, 1], ['0%', reduce ? '0%' : '30%'])
const heroY = useTransform(scrollYProgress, [0, 1], ['0%', reduce ? '0%' : '28%'])
const heroScale = useTransform(scrollYProgress, [0, 1], [1, reduce ? 1 : 1.12])
const heroFade = useTransform(scrollYProgress, [0, 0.8], [1, reduce ? 1 : 0.2])
const { data: banners } = useQuery({ queryKey: ['banners'], queryFn: getEventBanners })
const { data: recs } = useQuery({ queryKey: ['ai-rec'], queryFn: () => aiRecommend('', '', 8) })
const { data: bestData } = useQuery({ queryKey: ['best'], queryFn: () => getProducts({ sort: 'sales', size: 8 }) })
const { data: featData } = useQuery({ queryKey: ['feat'], queryFn: () => getProducts({ sort: 'rating', size: 12 }) })
const best = bestData?.items || []
const featured = featData?.items || []
const heroBanner = (banners || [])[0]
const heroImg = featured[0]?.thumbnail
const [idx, setIdx] = useState(0)
const [dir, setDir] = useState(1)
const count = HERO_SLIDES.length
const go = useCallback((next: number) => {
setDir(next > idx || (idx === count - 1 && next === 0) ? 1 : -1)
setIdx(((next % count) + count) % count)
}, [idx, count])
// 자동 회전 — prefers-reduced-motion 시 첫 슬라이드 고정
useEffect(() => {
if (reduce) return
const t = setInterval(() => {
setDir(1)
setIdx(i => (i + 1) % count)
}, SLIDE_INTERVAL)
return () => clearInterval(t)
}, [reduce, count])
const slide = HERO_SLIDES[idx]
return (
<div>
{/* ── 풀블리드 패럴랙스 히어로 ── */}
<section ref={heroRef} className="relative overflow-hidden min-h-[78vh] flex items-center">
{/* 배경 이미지 크로스페이드 (패럴랙스) */}
<motion.div style={{ y: heroY, scale: heroScale }} className="absolute inset-0 z-0">
{heroImg ? (
<img src={heroImg} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-blush-200 via-blush-100 to-sage-100" />
)}
<AnimatePresence initial={false}>
<motion.img
key={slide.img}
src={slide.img}
alt=""
className="absolute inset-0 w-full h-full object-cover"
initial={{ opacity: 0, scale: reduce ? 1 : 1.06 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 1.1, ease: [0.22, 1, 0.36, 1] }}
/>
</AnimatePresence>
<div className="absolute inset-0 bg-gradient-to-r from-blush-900/70 via-blush-900/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-sage-900/40 to-transparent" />
</motion.div>
<PetalLayer count={16} className="z-[1]" />
{/* 텍스트 슬라이드 — 헤드라인/문구/CTA 전환 */}
<motion.div style={{ opacity: heroFade }} className="relative z-10 max-w-6xl mx-auto px-4 w-full py-20">
<AnimatePresence mode="wait" custom={dir}>
<motion.div
initial={reduce ? false : { opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
key={idx}
className="max-w-xl"
custom={dir}
initial={reduce ? false : { opacity: 0, x: dir * 36 }}
animate={{ opacity: 1, x: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, x: dir * -36 }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
>
<div className="inline-flex items-center gap-2 text-cream/90 text-[12px] tracking-[0.25em] uppercase mb-5">
<span className="h-px w-8 bg-gold" /> {heroBanner?.eyebrow || BRAND.hero.eyebrow}
<span className="h-px w-8 bg-gold" /> {slide.eyebrow}
</div>
<h1 className="font-serif text-5xl md:text-6xl font-bold text-white leading-[1.05] mb-5 whitespace-pre-line drop-shadow-sm">
{heroBanner?.headline || BRAND.hero.title}
{slide.headline}
</h1>
<p className="text-cream/90 text-lg leading-relaxed mb-8 max-w-md font-light">
{heroBanner?.subtext || BRAND.hero.subtitle}
{slide.subtext}
{storeName ? ` Same-day from ${storeName}.` : ''}
</p>
<div className="flex flex-wrap gap-3">
<Link to={heroBanner?.linkUrl || '/category'}>
<Link to={slide.to}>
<Pressable className="inline-flex items-center gap-2 bg-white text-blush-700 font-semibold px-7 py-3.5 rounded-full shadow-bloom hover:bg-cream">
Shop the Collection <ArrowRight size={17} />
{slide.cta} <ArrowRight size={17} />
</Pressable>
</Link>
<Link to="/daily-standard">
@ -80,18 +104,61 @@ export default function Home() {
<span className="text-cream/60">· {BRAND.reviewCount.toLocaleString()} happy customers</span>
</div>
</motion.div>
</AnimatePresence>
</motion.div>
{/* 좌우 화살표 */}
<button
aria-label="Previous slide"
onClick={() => go(idx - 1)}
className="absolute left-3 md:left-5 top-1/2 -translate-y-1/2 z-20 w-11 h-11 rounded-full bg-white/15 hover:bg-white/30 backdrop-blur-sm text-white flex items-center justify-center transition-colors"
>
<ChevronLeft size={22} />
</button>
<button
aria-label="Next slide"
onClick={() => go(idx + 1)}
className="absolute right-3 md:right-5 top-1/2 -translate-y-1/2 z-20 w-11 h-11 rounded-full bg-white/15 hover:bg-white/30 backdrop-blur-sm text-white flex items-center justify-center transition-colors"
>
<ChevronRight size={22} />
</button>
{/* 인디케이터 (점) */}
<div className="absolute bottom-7 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2.5">
{HERO_SLIDES.map((_, i) => (
<button
key={i}
aria-label={`Go to slide ${i + 1}`}
onClick={() => go(i)}
className={`h-2.5 rounded-full transition-all duration-300 ${i === idx ? 'w-8 bg-white' : 'w-2.5 bg-white/45 hover:bg-white/70'}`}
/>
))}
</div>
{/* sway 보태니컬 장식 */}
<motion.div
aria-hidden
className="absolute -bottom-6 right-6 z-[2] text-white/20 hidden md:block"
className="absolute -bottom-6 right-6 z-[2] text-white/20 hidden md:block pointer-events-none"
animate={reduce ? undefined : { rotate: [0, 5, -4, 0], y: [0, -8, 0] }}
transition={{ duration: 9, repeat: Infinity, ease: 'easeInOut' }}
>
<Flower2 size={130} strokeWidth={1} />
</motion.div>
</section>
)
}
export default function Home() {
const { data: recs } = useQuery({ queryKey: ['ai-rec'], queryFn: () => aiRecommend('', '', 8) })
const { data: bestData } = useQuery({ queryKey: ['best'], queryFn: () => getProducts({ sort: 'sales', size: 8 }) })
const { data: featData } = useQuery({ queryKey: ['feat'], queryFn: () => getProducts({ sort: 'rating', size: 12 }) })
const best = bestData?.items || []
const featured = featData?.items || []
return (
<div>
{/* ── 풀블리드 회전 히어로 캐러셀 (3슬라이드) ── */}
<HeroCarousel />
{/* ── 브랜드 약속 (3대 헌신) ── */}
<section className="bg-ivory border-b border-blush-100">

View File

@ -43,3 +43,32 @@ export const BRAND = {
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.',
}
// 회전 히어로 슬라이드 3종 — Montvale(montvalefloristnj.com) hero 캐러셀 원문 그대로.
// 이미지는 원본 Cloudinary hero를 public/img/hero/에 그대로 캡쳐.
export const HERO_SLIDES = [
{
img: '/img/hero/slide-1.jpg',
eyebrow: 'Birthday Blooms',
headline: 'Make Their Birthday\nUnforgettable',
subtext: 'Florist-designed bouquets, hand-delivered the same day — a celebration in every petal.',
cta: 'Find the Perfect Gift',
to: '/category?occasion=BIRTHDAY',
},
{
img: '/img/hero/slide-2.jpg',
eyebrow: 'Sympathy & Comfort',
headline: 'Honor Their Memory\nwith Heartfelt Flowers',
subtext: 'Thoughtful tributes, gently arranged and delivered with care and compassion.',
cta: 'Send Your Condolences',
to: '/category?occasion=SYMPATHY',
},
{
img: '/img/hero/slide-3.jpg',
eyebrow: 'Just Because',
headline: 'Brighten Their Day,\nJust Because',
subtext: 'No occasion needed — send a smile with fresh, locally designed blooms.',
cta: 'Send a Smile',
to: '/category?occasion=JUST_BECAUSE',
},
] as const