feat: add ProductCard and ServiceCard components
Implement ProductCard component with: - Renders product details (name, price, description) - "Ask about this" button disabled when out of stock - Shows "Currently unavailable" badge when out of stock - Calls onInquire callback when clicked Implement ServiceCard component with: - Renders service details (name, formatted duration, price) - Clickable button card that calls onSelect with service id - formatDuration helper for human-readable time format Both components include CSS modules with hover effects and proper styling. All 4 tests pass (2 for each component). Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
33
src/components/ProductCard.module.css
Normal file
33
src/components/ProductCard.module.css
Normal file
@@ -0,0 +1,33 @@
|
||||
.card {
|
||||
background: var(--color-cream-alt);
|
||||
border-radius: 4px;
|
||||
padding: var(--space-md);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-6px) rotateX(2deg);
|
||||
}
|
||||
|
||||
.badge {
|
||||
color: #a0453a;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: var(--color-charcoal);
|
||||
color: var(--color-cream);
|
||||
border: none;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
background: #b7ada3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
28
src/components/ProductCard.test.tsx
Normal file
28
src/components/ProductCard.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import ProductCard from './ProductCard'
|
||||
import type { Product } from '../data/products'
|
||||
|
||||
const product: Product = {
|
||||
id: 'edge-control', name: 'Silk Edge Control', category: 'hair-care',
|
||||
price: 12, image: '/x.jpg', description: 'Hold with shine.', inStock: true,
|
||||
}
|
||||
|
||||
describe('ProductCard', () => {
|
||||
it('renders product details and calls onInquire when clicked', async () => {
|
||||
const onInquire = vi.fn()
|
||||
render(<ProductCard product={product} onInquire={onInquire} />)
|
||||
expect(screen.getByText('Silk Edge Control')).toBeInTheDocument()
|
||||
expect(screen.getByText('$12')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: /ask about this/i }))
|
||||
expect(onInquire).toHaveBeenCalledWith(product)
|
||||
})
|
||||
|
||||
it('disables the inquire button when out of stock', () => {
|
||||
const onInquire = vi.fn()
|
||||
render(<ProductCard product={{ ...product, inStock: false }} onInquire={onInquire} />)
|
||||
expect(screen.getByRole('button', { name: /ask about this/i })).toBeDisabled()
|
||||
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
28
src/components/ProductCard.tsx
Normal file
28
src/components/ProductCard.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Product } from '../data/products'
|
||||
import styles from './ProductCard.module.css'
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product
|
||||
onInquire: (product: Product) => void
|
||||
}
|
||||
|
||||
function ProductCard({ product, onInquire }: ProductCardProps) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h3>{product.name}</h3>
|
||||
<p>${product.price}</p>
|
||||
<p>{product.description}</p>
|
||||
{!product.inStock && <p className={styles.badge}>Currently unavailable</p>}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
disabled={!product.inStock}
|
||||
onClick={() => onInquire(product)}
|
||||
>
|
||||
Ask about this
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductCard
|
||||
15
src/components/ServiceCard.module.css
Normal file
15
src/components/ServiceCard.module.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.card {
|
||||
background: var(--color-cream-alt);
|
||||
border-radius: 4px;
|
||||
padding: var(--space-md);
|
||||
min-width: 160px;
|
||||
text-align: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.25s ease;
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-6px) rotateX(2deg);
|
||||
}
|
||||
26
src/components/ServiceCard.test.tsx
Normal file
26
src/components/ServiceCard.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import ServiceCard from './ServiceCard'
|
||||
import type { Service } from '../data/services'
|
||||
|
||||
const service: Service = {
|
||||
id: 'knotless', name: 'Knotless Braids', durationMinutes: 240,
|
||||
priceFrom: 150, description: 'Tension-free braids.', image: '/x.jpg',
|
||||
}
|
||||
|
||||
describe('ServiceCard', () => {
|
||||
it('renders service details', () => {
|
||||
render(<ServiceCard service={service} />)
|
||||
expect(screen.getByText('Knotless Braids')).toBeInTheDocument()
|
||||
expect(screen.getByText(/from \$150/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('4h')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with the service id when clicked', async () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<ServiceCard service={service} onSelect={onSelect} />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(onSelect).toHaveBeenCalledWith('knotless')
|
||||
})
|
||||
})
|
||||
25
src/components/ServiceCard.tsx
Normal file
25
src/components/ServiceCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Service } from '../data/services'
|
||||
import styles from './ServiceCard.module.css'
|
||||
|
||||
interface ServiceCardProps {
|
||||
service: Service
|
||||
onSelect?: (serviceId: string) => void
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`
|
||||
}
|
||||
|
||||
function ServiceCard({ service, onSelect }: ServiceCardProps) {
|
||||
return (
|
||||
<button type="button" className={styles.card} onClick={() => onSelect?.(service.id)}>
|
||||
<h3>{service.name}</h3>
|
||||
<p>{formatDuration(service.durationMinutes)}</p>
|
||||
<p>From ${service.priceFrom}</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServiceCard
|
||||
Reference in New Issue
Block a user