From 88379caee251e24ac07c2db9b5750b023fe4f0cc Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Mon, 3 Aug 2026 17:27:31 +0000 Subject: [PATCH] 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 --- src/components/ProductCard.module.css | 33 +++++++++++++++++++++++++++ src/components/ProductCard.test.tsx | 28 +++++++++++++++++++++++ src/components/ProductCard.tsx | 28 +++++++++++++++++++++++ src/components/ServiceCard.module.css | 15 ++++++++++++ src/components/ServiceCard.test.tsx | 26 +++++++++++++++++++++ src/components/ServiceCard.tsx | 25 ++++++++++++++++++++ 6 files changed, 155 insertions(+) create mode 100644 src/components/ProductCard.module.css create mode 100644 src/components/ProductCard.test.tsx create mode 100644 src/components/ProductCard.tsx create mode 100644 src/components/ServiceCard.module.css create mode 100644 src/components/ServiceCard.test.tsx create mode 100644 src/components/ServiceCard.tsx diff --git a/src/components/ProductCard.module.css b/src/components/ProductCard.module.css new file mode 100644 index 0000000..3b73999 --- /dev/null +++ b/src/components/ProductCard.module.css @@ -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; +} diff --git a/src/components/ProductCard.test.tsx b/src/components/ProductCard.test.tsx new file mode 100644 index 0000000..b976639 --- /dev/null +++ b/src/components/ProductCard.test.tsx @@ -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() + 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() + expect(screen.getByRole('button', { name: /ask about this/i })).toBeDisabled() + expect(screen.getByText(/unavailable/i)).toBeInTheDocument() + }) +}) diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx new file mode 100644 index 0000000..a1c058f --- /dev/null +++ b/src/components/ProductCard.tsx @@ -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 ( +
+

{product.name}

+

${product.price}

+

{product.description}

+ {!product.inStock &&

Currently unavailable

} + +
+ ) +} + +export default ProductCard diff --git a/src/components/ServiceCard.module.css b/src/components/ServiceCard.module.css new file mode 100644 index 0000000..1e10c6c --- /dev/null +++ b/src/components/ServiceCard.module.css @@ -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); +} diff --git a/src/components/ServiceCard.test.tsx b/src/components/ServiceCard.test.tsx new file mode 100644 index 0000000..9e2d8c4 --- /dev/null +++ b/src/components/ServiceCard.test.tsx @@ -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() + 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() + await userEvent.click(screen.getByRole('button')) + expect(onSelect).toHaveBeenCalledWith('knotless') + }) +}) diff --git a/src/components/ServiceCard.tsx b/src/components/ServiceCard.tsx new file mode 100644 index 0000000..8ffeb90 --- /dev/null +++ b/src/components/ServiceCard.tsx @@ -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 ( + + ) +} + +export default ServiceCard