feat: wire Home page to live site content, testimonials, and featured products
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
@@ -5,9 +5,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const productsOrderMock = vi.fn()
|
||||
const servicesOrderMock = vi.fn()
|
||||
const fromMock = vi.fn<(table: string) => { select: () => { order: typeof productsOrderMock } }>((table: string) => {
|
||||
const testimonialsOrderMock = vi.fn()
|
||||
const siteContentSingleMock = vi.fn()
|
||||
const siteContentEqMock = vi.fn(() => ({ single: siteContentSingleMock }))
|
||||
const fromMock = vi.fn<(table: string) => { select: () => { order: typeof productsOrderMock } | { eq: typeof siteContentEqMock } }>((table: string) => {
|
||||
if (table === 'products') return { select: () => ({ order: productsOrderMock }) }
|
||||
if (table === 'services') return { select: () => ({ order: servicesOrderMock }) }
|
||||
if (table === 'testimonials') return { select: () => ({ order: testimonialsOrderMock }) }
|
||||
if (table === 'site_content') return { select: () => ({ eq: siteContentEqMock }) }
|
||||
throw new Error(`unexpected table: ${table}`)
|
||||
})
|
||||
|
||||
@@ -19,11 +24,24 @@ vi.mock('../lib/resolveImageUrl', () => ({
|
||||
}))
|
||||
|
||||
const PRODUCT_ROWS = [
|
||||
{ id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '', description: '', in_stock: true },
|
||||
{ id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '', description: '', in_stock: true, featured: true, featured_order: 0 },
|
||||
{ id: 'braid-oil', name: 'Huile Nourrissante', category: 'hair-care', price: 16, image: '', description: '', in_stock: true, featured: false, featured_order: null },
|
||||
]
|
||||
const SERVICE_ROWS = [
|
||||
{ id: 'knotless', name: 'Tresses sans nœuds', duration_minutes: 240, price_from: 150, description: '', image: '' },
|
||||
]
|
||||
const TESTIMONIAL_ROWS = [
|
||||
{ id: 't1', quote: 'Fantastique service !', author: 'Amara T.', avatar_image: '', sort_order: 0 },
|
||||
]
|
||||
const SITE_CONTENT_ROW = {
|
||||
id: 'default',
|
||||
hero_headline: 'Vos cheveux, votre couronne',
|
||||
hero_tagline: 'Coiffures protectrices par Mira',
|
||||
about_bio: 'Bio de test.',
|
||||
about_portrait_image: '',
|
||||
contact_email: 'hello@braidsbymira.com',
|
||||
contact_hours: 'Mar–Sam, 9h à 18h',
|
||||
}
|
||||
|
||||
import Home from './Home'
|
||||
import Book from './Book'
|
||||
@@ -43,24 +61,33 @@ describe('Home page', () => {
|
||||
beforeEach(() => {
|
||||
productsOrderMock.mockReset().mockResolvedValue({ data: PRODUCT_ROWS, error: null })
|
||||
servicesOrderMock.mockReset().mockResolvedValue({ data: SERVICE_ROWS, error: null })
|
||||
testimonialsOrderMock.mockReset().mockResolvedValue({ data: TESTIMONIAL_ROWS, error: null })
|
||||
siteContentSingleMock.mockReset().mockResolvedValue({ data: SITE_CONTENT_ROW, error: null })
|
||||
fromMock.mockClear()
|
||||
})
|
||||
|
||||
it('renders the hero headline and all signature styles', async () => {
|
||||
renderHome()
|
||||
expect(screen.getByText(/vos cheveux, votre couronne/i)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/vos cheveux, votre couronne/i)).toBeInTheDocument()
|
||||
for (const service of SERVICE_ROWS) {
|
||||
expect(await screen.findByText(service.name)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders shop highlights and a link to the rules page', async () => {
|
||||
it('renders only featured products in shop highlights, and a link to the rules page', async () => {
|
||||
renderHome()
|
||||
await screen.findByText('Gel Contour Soyeux')
|
||||
expect(await screen.findByText('Gel Contour Soyeux')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Huile Nourrissante')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /voir toute la boutique/i })).toHaveAttribute('href', '/shop')
|
||||
expect(screen.getByRole('link', { name: /règlements du salon/i })).toHaveAttribute('href', '/rules')
|
||||
})
|
||||
|
||||
it('renders testimonials from site content', async () => {
|
||||
renderHome()
|
||||
expect(await screen.findByText(/fantastique service/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Amara T.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates to /book with the chosen service preselected when a signature style is clicked', async () => {
|
||||
renderHome()
|
||||
const button = await screen.findByRole('button', { name: new RegExp(SERVICE_ROWS[0].name) })
|
||||
@@ -68,8 +95,9 @@ describe('Home page', () => {
|
||||
expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the About Mira portrait', () => {
|
||||
it('renders the About Mira portrait and bio from site content', async () => {
|
||||
renderHome()
|
||||
expect(await screen.findByText('Bio de test.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: /mira/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,14 +7,10 @@ import ServiceCard from '../components/ServiceCard'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import { fetchServices, type Service } from '../data/services'
|
||||
import { fetchProducts, type Product } from '../data/products'
|
||||
import { fetchTestimonials, type Testimonial } from '../data/testimonials'
|
||||
import { fetchSiteContent, type SiteContent } from '../data/siteContent'
|
||||
import styles from './Home.module.css'
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{ quote: "Mira m'a fait sentir comme une reine sur son fauteuil. Mes tresses ont tenu 8 semaines !", author: 'Amara T.', avatar: '/testimonials/amara.jpg' },
|
||||
{ quote: "La tresseuse la plus douce et la plus précise que j'aie jamais consultée.", author: 'Keisha R.', avatar: '/testimonials/keisha.jpg' },
|
||||
{ quote: 'La réservation était si simple, et le résultat parle de lui-même.', author: 'Nadia F.', avatar: '/testimonials/nadia.jpg' },
|
||||
]
|
||||
|
||||
function Home() {
|
||||
const navigate = useNavigate()
|
||||
const { scrollYProgress } = useScroll()
|
||||
@@ -23,15 +19,24 @@ function Home() {
|
||||
|
||||
const [services, setServices] = useState<Service[]>([])
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [testimonials, setTestimonials] = useState<Testimonial[]>([])
|
||||
const [content, setContent] = useState<SiteContent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [servicesResult, productsResult] = await Promise.all([fetchServices(), fetchProducts()])
|
||||
const [servicesResult, productsResult, testimonialsResult, contentResult] = await Promise.all([
|
||||
fetchServices(),
|
||||
fetchProducts(),
|
||||
fetchTestimonials(),
|
||||
fetchSiteContent(),
|
||||
])
|
||||
setServices(servicesResult)
|
||||
setProducts(productsResult)
|
||||
setTestimonials(testimonialsResult)
|
||||
setContent(contentResult)
|
||||
} catch (err) {
|
||||
setError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err))
|
||||
} finally {
|
||||
@@ -41,12 +46,16 @@ function Home() {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const featuredProducts = products
|
||||
.filter((p) => p.featured)
|
||||
.sort((a, b) => (a.featuredOrder ?? 0) - (b.featuredOrder ?? 0))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className={styles.hero}>
|
||||
<Hero3DScene />
|
||||
<h1 className={styles.headline}>Vos cheveux, votre couronne</h1>
|
||||
<p className={styles.tagline}>Coiffures protectrices par Mira</p>
|
||||
<h1 className={styles.headline}>{content?.heroHeadline ?? 'Vos cheveux, votre couronne'}</h1>
|
||||
<p className={styles.tagline}>{content?.heroTagline ?? 'Coiffures protectrices par Mira'}</p>
|
||||
<div className={styles.ctas}>
|
||||
<Link to="/book">Réserver maintenant</Link>
|
||||
<Link to="/shop">Voir les produits</Link>
|
||||
@@ -60,18 +69,14 @@ function Home() {
|
||||
<section className={styles.altSection}>
|
||||
<div className={styles.aboutGrid}>
|
||||
<motion.img
|
||||
src="/about/mira-portrait.jpg"
|
||||
src={content?.aboutPortraitImage}
|
||||
alt="Mira, propriétaire et coiffeuse chez Braids by Mira"
|
||||
className={styles.portrait}
|
||||
style={{ y: portraitY }}
|
||||
/>
|
||||
<div>
|
||||
<h2>À propos de Mira</h2>
|
||||
<p>
|
||||
Avec plus d'une décennie d'expérience derrière le fauteuil, Mira aborde chaque
|
||||
installation comme une œuvre d'art — douce pour le cuir chevelu, précise à la
|
||||
racine, et achevée pour une couronne que vous aurez envie d'afficher.
|
||||
</p>
|
||||
<p>{content?.aboutBio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -96,7 +101,7 @@ function Home() {
|
||||
<section className={styles.altSection}>
|
||||
<h2>Sélection boutique</h2>
|
||||
<div className={styles.row}>
|
||||
{products.filter((p) => p.inStock).slice(0, 3).map((product) => (
|
||||
{featuredProducts.map((product) => (
|
||||
<ProductCard key={product.id} product={product} onInquire={() => navigate('/shop')} />
|
||||
))}
|
||||
</div>
|
||||
@@ -108,9 +113,9 @@ function Home() {
|
||||
<section className={styles.section}>
|
||||
<h2>Avis de nos clientes</h2>
|
||||
<div className={styles.row}>
|
||||
{TESTIMONIALS.map((t) => (
|
||||
<blockquote key={t.author} className={styles.testimonial}>
|
||||
<img src={t.avatar} alt={t.author} className={styles.avatar} />
|
||||
{testimonials.map((t) => (
|
||||
<blockquote key={t.id} className={styles.testimonial}>
|
||||
<img src={t.avatarImage} alt={t.author} className={styles.avatar} />
|
||||
<p>“{t.quote}”</p>
|
||||
<cite>{t.author}</cite>
|
||||
</blockquote>
|
||||
|
||||
Reference in New Issue
Block a user