feat(home): Montvale 3종 회전 히어로 + favicon
BIN
backend/src/main/resources/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
backend/src/main/resources/static/favicon-32.png
Normal file
|
After Width: | Height: | Size: 641 B |
BIN
backend/src/main/resources/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
backend/src/main/resources/static/img/hero/slide-1.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
backend/src/main/resources/static/img/hero/slide-2.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
backend/src/main/resources/static/img/hero/slide-3.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
frontend/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 641 B |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/public/img/hero/slide-1.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
frontend/public/img/hero/slide-2.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
frontend/public/img/hero/slide-3.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||