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:
belviskhoremk
2026-08-03 17:27:31 +00:00
parent 8c88f2a891
commit 88379caee2
6 changed files with 155 additions and 0 deletions

View 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;
}

View 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()
})
})

View 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

View 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);
}

View 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')
})
})

View 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