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