Complete Phase 3: Feature components and micro-interactions
- Add WorkflowStep component with active/inactive states - Add WorkflowCarousel with auto-advance, keyboard nav, progress dots - Add ExpandableServiceCard with controlled/uncontrolled expand - Add BentoCard with lg/md/sm responsive layouts - Add BentoGrid section with asymmetric card layout - Add shared animation utilities and micro-interactions - Enhance Header logo with hover effects - Fix Header test flakiness with waitFor Components: WorkflowStep, WorkflowCarousel, ExpandableServiceCard, BentoCard, BentoGrid Data files: workflow.ts, services.ts Total: 227 passing tests (80 new tests added) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
54
src/App.tsx
54
src/App.tsx
@ -1,6 +1,5 @@
|
|||||||
import { Header, Footer, Container } from './components/layout'
|
import { Header, Footer } from './components/layout'
|
||||||
import { Card } from './components/ui/Card'
|
import { Hero, Stats, Contact, WorkflowCarousel, BentoGrid } from './components/sections'
|
||||||
import { Hero, Stats, Contact } from './components/sections'
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -15,52 +14,11 @@ function App() {
|
|||||||
{/* Stats Section */}
|
{/* Stats Section */}
|
||||||
<Stats />
|
<Stats />
|
||||||
|
|
||||||
{/* Services Section Placeholder */}
|
{/* Services Section - Bento Grid */}
|
||||||
<section id="services" className="py-20">
|
<BentoGrid />
|
||||||
<Container>
|
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-center text-text-primary mb-12">
|
|
||||||
Our Services
|
|
||||||
</h2>
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
|
||||||
<Card variant="default" padding="lg">
|
|
||||||
<h3 className="text-xl font-semibold text-text-primary mb-3">
|
|
||||||
Web Development
|
|
||||||
</h3>
|
|
||||||
<p className="text-text-secondary">
|
|
||||||
Modern, responsive websites built with the latest technologies.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
<Card variant="elevated" padding="lg">
|
|
||||||
<h3 className="text-xl font-semibold text-text-primary mb-3">
|
|
||||||
Mobile Apps
|
|
||||||
</h3>
|
|
||||||
<p className="text-text-secondary">
|
|
||||||
Native and cross-platform mobile applications.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
<Card variant="outlined" padding="lg">
|
|
||||||
<h3 className="text-xl font-semibold text-text-primary mb-3">
|
|
||||||
Cloud Solutions
|
|
||||||
</h3>
|
|
||||||
<p className="text-text-secondary">
|
|
||||||
Scalable infrastructure and cloud architecture.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Process Section Placeholder */}
|
{/* Process Section - Workflow Carousel */}
|
||||||
<section id="process" className="py-20 bg-bg-secondary">
|
<WorkflowCarousel />
|
||||||
<Container>
|
|
||||||
<h2 className="text-3xl md:text-4xl font-bold text-center text-text-primary mb-12">
|
|
||||||
Our Process
|
|
||||||
</h2>
|
|
||||||
<p className="text-center text-text-secondary max-w-2xl mx-auto">
|
|
||||||
Phase 3 will add the animated workflow carousel here.
|
|
||||||
</p>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Contact Section */}
|
{/* Contact Section */}
|
||||||
<Contact />
|
<Contact />
|
||||||
|
|||||||
128
src/components/features/BentoCard.test.tsx
Normal file
128
src/components/features/BentoCard.test.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { BentoCard } from './BentoCard'
|
||||||
|
import type { BentoCardService } from '../../data/services'
|
||||||
|
|
||||||
|
const mockService: BentoCardService = {
|
||||||
|
id: 'web-apps',
|
||||||
|
icon: 'Globe',
|
||||||
|
title: 'Web Applications',
|
||||||
|
summary: 'Full-stack solutions for modern web',
|
||||||
|
details: 'Modern web applications built with React, Node.js, and cloud infrastructure.',
|
||||||
|
technologies: ['React', 'Node.js', 'AWS'],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BentoCard', () => {
|
||||||
|
it('renders service title', () => {
|
||||||
|
render(<BentoCard service={mockService} size="lg" />)
|
||||||
|
expect(screen.getByText('Web Applications')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders service icon', () => {
|
||||||
|
render(<BentoCard service={mockService} size="lg" />)
|
||||||
|
expect(screen.getByTestId('bento-card-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders summary', () => {
|
||||||
|
render(<BentoCard service={mockService} size="lg" />)
|
||||||
|
expect(screen.getByText('Full-stack solutions for modern web')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BentoCard sizes', () => {
|
||||||
|
it('applies large layout classes when size="lg"', () => {
|
||||||
|
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
|
||||||
|
const card = screen.getByTestId('card')
|
||||||
|
expect(card).toHaveClass('col-span-2')
|
||||||
|
expect(card).toHaveClass('row-span-2')
|
||||||
|
expect(card).toHaveClass('p-8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies medium layout classes when size="md"', () => {
|
||||||
|
render(<BentoCard service={mockService} size="md" data-testid="card" />)
|
||||||
|
const card = screen.getByTestId('card')
|
||||||
|
expect(card).toHaveClass('col-span-2')
|
||||||
|
expect(card).toHaveClass('row-span-1')
|
||||||
|
expect(card).toHaveClass('p-6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies small layout classes when size="sm"', () => {
|
||||||
|
render(<BentoCard service={mockService} size="sm" data-testid="card" />)
|
||||||
|
const card = screen.getByTestId('card')
|
||||||
|
expect(card).toHaveClass('col-span-1')
|
||||||
|
expect(card).toHaveClass('row-span-1')
|
||||||
|
expect(card).toHaveClass('p-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows technologies only for large size', () => {
|
||||||
|
const { rerender } = render(<BentoCard service={mockService} size="lg" />)
|
||||||
|
expect(screen.getByText('React')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Node.js')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('AWS')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<BentoCard service={mockService} size="md" />)
|
||||||
|
expect(screen.queryByText('React')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<BentoCard service={mockService} size="sm" />)
|
||||||
|
expect(screen.queryByText('React')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows details only for large and medium sizes', () => {
|
||||||
|
const { rerender } = render(<BentoCard service={mockService} size="lg" />)
|
||||||
|
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<BentoCard service={mockService} size="md" />)
|
||||||
|
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<BentoCard service={mockService} size="sm" />)
|
||||||
|
expect(screen.queryByText(/Modern web applications built with React/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges custom className', () => {
|
||||||
|
render(<BentoCard service={mockService} size="lg" className="custom-class" data-testid="card" />)
|
||||||
|
const card = screen.getByTestId('card')
|
||||||
|
expect(card).toHaveClass('custom-class')
|
||||||
|
expect(card).toHaveClass('col-span-2') // Still has base classes
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BentoCard interactions', () => {
|
||||||
|
it('applies hover effect classes', () => {
|
||||||
|
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
|
||||||
|
const card = screen.getByTestId('card')
|
||||||
|
expect(card).toHaveClass('hover:-translate-y-1')
|
||||||
|
expect(card).toHaveClass('hover:shadow-xl')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has transition classes for animations', () => {
|
||||||
|
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
|
||||||
|
const card = screen.getByTestId('card')
|
||||||
|
expect(card).toHaveClass('transition-all')
|
||||||
|
expect(card).toHaveClass('duration-300')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has border and rounded corners', () => {
|
||||||
|
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
|
||||||
|
const card = screen.getByTestId('card')
|
||||||
|
expect(card).toHaveClass('border')
|
||||||
|
expect(card).toHaveClass('border-border')
|
||||||
|
expect(card).toHaveClass('rounded-2xl')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BentoCard medium layout', () => {
|
||||||
|
it('uses horizontal flex layout for medium size', () => {
|
||||||
|
render(<BentoCard service={mockService} size="md" data-testid="card" />)
|
||||||
|
const content = screen.getByTestId('card').firstChild
|
||||||
|
expect(content).toHaveClass('flex')
|
||||||
|
expect(content).toHaveClass('gap-4')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BentoCard small layout', () => {
|
||||||
|
it('centers content for small size', () => {
|
||||||
|
render(<BentoCard service={mockService} size="sm" data-testid="card" />)
|
||||||
|
const content = screen.getByTestId('card').firstChild
|
||||||
|
expect(content).toHaveClass('text-center')
|
||||||
|
})
|
||||||
|
})
|
||||||
159
src/components/features/BentoCard.tsx
Normal file
159
src/components/features/BentoCard.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { Globe, Smartphone, Cloud, Plug, Shield, Lightbulb, type LucideIcon } from 'lucide-react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import type { BentoCardService } from '../../data/services'
|
||||||
|
|
||||||
|
const iconMap: Record<string, LucideIcon> = {
|
||||||
|
Globe,
|
||||||
|
Smartphone,
|
||||||
|
Cloud,
|
||||||
|
Plug,
|
||||||
|
Shield,
|
||||||
|
Lightbulb,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BentoCardProps {
|
||||||
|
/** Service data */
|
||||||
|
service: BentoCardService
|
||||||
|
/** Card size determines layout */
|
||||||
|
size: 'lg' | 'md' | 'sm'
|
||||||
|
/** Additional CSS classes (for grid positioning) */
|
||||||
|
className?: string
|
||||||
|
/** Test ID */
|
||||||
|
'data-testid'?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
lg: 'col-span-2 row-span-2 p-8',
|
||||||
|
md: 'col-span-2 row-span-1 p-6',
|
||||||
|
sm: 'col-span-1 row-span-1 p-4',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BentoCard({
|
||||||
|
service,
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
'data-testid': testId,
|
||||||
|
}: BentoCardProps) {
|
||||||
|
const Icon = iconMap[service.icon] || Globe
|
||||||
|
const showDetails = size === 'lg' || size === 'md'
|
||||||
|
const showTechnologies = size === 'lg'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={testId}
|
||||||
|
className={cn(
|
||||||
|
'bg-white border border-border rounded-2xl shadow-md',
|
||||||
|
'transition-all duration-300 ease-in-out',
|
||||||
|
'hover:-translate-y-1 hover:shadow-xl',
|
||||||
|
'group overflow-hidden',
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{size === 'lg' && (
|
||||||
|
<LargeLayout
|
||||||
|
icon={Icon}
|
||||||
|
title={service.title}
|
||||||
|
summary={service.summary}
|
||||||
|
details={service.details}
|
||||||
|
technologies={service.technologies}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{size === 'md' && (
|
||||||
|
<MediumLayout
|
||||||
|
icon={Icon}
|
||||||
|
title={service.title}
|
||||||
|
summary={service.summary}
|
||||||
|
details={service.details}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{size === 'sm' && (
|
||||||
|
<SmallLayout icon={Icon} title={service.title} summary={service.summary} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LargeLayoutProps {
|
||||||
|
icon: LucideIcon
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
details: string
|
||||||
|
technologies: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function LargeLayout({ icon: Icon, title, summary, details, technologies }: LargeLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div
|
||||||
|
data-testid="bento-card-icon"
|
||||||
|
className="p-3 rounded-xl bg-primary/10 text-primary w-fit mb-4 group-hover:scale-110 transition-transform duration-300"
|
||||||
|
>
|
||||||
|
<Icon className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-2xl font-bold text-foreground mb-2">{title}</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">{summary}</p>
|
||||||
|
<p className="text-foreground flex-1">{details}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mt-4">
|
||||||
|
{technologies.map((tech, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-3 py-1 bg-primary/10 text-primary text-sm rounded-full font-medium"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediumLayoutProps {
|
||||||
|
icon: LucideIcon
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
details: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediumLayout({ icon: Icon, title, summary, details }: MediumLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<div
|
||||||
|
data-testid="bento-card-icon"
|
||||||
|
className="p-3 rounded-xl bg-primary/10 text-primary shrink-0 group-hover:scale-110 transition-transform duration-300"
|
||||||
|
>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-xl font-bold text-foreground mb-1">{title}</h3>
|
||||||
|
<p className="text-muted-foreground text-sm mb-2">{summary}</p>
|
||||||
|
<p className="text-foreground text-sm">{details}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SmallLayoutProps {
|
||||||
|
icon: LucideIcon
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmallLayout({ icon: Icon, title, summary }: SmallLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="text-center flex flex-col items-center justify-center h-full">
|
||||||
|
<div
|
||||||
|
data-testid="bento-card-icon"
|
||||||
|
className="p-3 rounded-xl bg-primary/10 text-primary mb-3 group-hover:scale-110 transition-transform duration-300"
|
||||||
|
>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold text-foreground mb-1">{title}</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">{summary}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
src/components/features/ExpandableServiceCard.test.tsx
Normal file
166
src/components/features/ExpandableServiceCard.test.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { Globe } from 'lucide-react'
|
||||||
|
import { ExpandableServiceCard } from './ExpandableServiceCard'
|
||||||
|
|
||||||
|
describe('ExpandableServiceCard', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
icon: Globe,
|
||||||
|
title: 'Web Applications',
|
||||||
|
summary: 'Full-stack solutions for modern web',
|
||||||
|
details: 'Modern web applications built with React, Node.js, and cloud infrastructure. From MVPs to enterprise platforms.',
|
||||||
|
capabilities: ['Custom dashboards', 'E-commerce platforms', 'SaaS applications'],
|
||||||
|
technologies: ['React', 'Node.js', 'AWS'],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders title correctly', () => {
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Web Applications')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders icon correctly', () => {
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
expect(screen.getByTestId('service-card-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders summary in collapsed state', () => {
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Full-stack solutions for modern web')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is collapsed by default', () => {
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
const card = screen.getByRole('button')
|
||||||
|
expect(card).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expands when clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
|
||||||
|
const card = screen.getByRole('button')
|
||||||
|
await user.click(card)
|
||||||
|
|
||||||
|
expect(card).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses when clicked again', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
|
||||||
|
const card = screen.getByRole('button')
|
||||||
|
await user.click(card) // expand
|
||||||
|
await user.click(card) // collapse
|
||||||
|
|
||||||
|
expect(card).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows details when expanded', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows capabilities list when expanded', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
expect(screen.getByText('Custom dashboards')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('E-commerce platforms')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('SaaS applications')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows technology tags when expanded', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
expect(screen.getByText('React')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Node.js')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('AWS')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides details when collapsed', () => {
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
expect(screen.queryByText(/Modern web applications built with React/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses controlled state when isExpanded prop provided', () => {
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} isExpanded={true} />)
|
||||||
|
const card = screen.getByRole('button')
|
||||||
|
expect(card).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onToggle when clicked (controlled)', async () => {
|
||||||
|
const handleToggle = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} isExpanded={false} onToggle={handleToggle} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
expect(handleToggle).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies cursor-pointer style', () => {
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
const card = screen.getByRole('button')
|
||||||
|
expect(card).toHaveClass('cursor-pointer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is keyboard accessible with Enter', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
|
||||||
|
const card = screen.getByRole('button')
|
||||||
|
card.focus()
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
|
||||||
|
expect(card).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is keyboard accessible with Space', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
|
||||||
|
const card = screen.getByRole('button')
|
||||||
|
card.focus()
|
||||||
|
await user.keyboard(' ')
|
||||||
|
|
||||||
|
expect(card).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has gradient border styling', () => {
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
const card = screen.getByRole('button')
|
||||||
|
expect(card).toHaveClass('before:bg-gradient-to-r')
|
||||||
|
expect(card).toHaveClass('before:from-primary')
|
||||||
|
expect(card).toHaveClass('before:to-accent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works without technologies prop', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const propsWithoutTech = { ...defaultProps }
|
||||||
|
delete (propsWithoutTech as any).technologies
|
||||||
|
|
||||||
|
render(<ExpandableServiceCard {...propsWithoutTech} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
expect(screen.getByText('Custom dashboards')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('React')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows expand indicator when collapsed', () => {
|
||||||
|
render(<ExpandableServiceCard {...defaultProps} />)
|
||||||
|
expect(screen.getByTestId('expand-indicator')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
162
src/components/features/ExpandableServiceCard.tsx
Normal file
162
src/components/features/ExpandableServiceCard.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ChevronDown, ChevronUp, type LucideIcon } from 'lucide-react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
interface ExpandableServiceCardProps {
|
||||||
|
/** Lucide icon component */
|
||||||
|
icon: LucideIcon
|
||||||
|
/** Service title */
|
||||||
|
title: string
|
||||||
|
/** Short summary for collapsed state */
|
||||||
|
summary: string
|
||||||
|
/** Full description for expanded state */
|
||||||
|
details: string
|
||||||
|
/** List of capabilities/features */
|
||||||
|
capabilities: string[]
|
||||||
|
/** Technology tags (optional) */
|
||||||
|
technologies?: string[]
|
||||||
|
/** External control: is expanded */
|
||||||
|
isExpanded?: boolean
|
||||||
|
/** External control: toggle callback */
|
||||||
|
onToggle?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentVariants = {
|
||||||
|
hidden: {
|
||||||
|
opacity: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
height: 'auto',
|
||||||
|
transition: { duration: 0.3, ease: 'easeOut' },
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
height: 0,
|
||||||
|
transition: { duration: 0.2, ease: 'easeIn' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpandableServiceCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
details,
|
||||||
|
capabilities,
|
||||||
|
technologies,
|
||||||
|
isExpanded: controlledExpanded,
|
||||||
|
onToggle,
|
||||||
|
}: ExpandableServiceCardProps) {
|
||||||
|
const [internalExpanded, setInternalExpanded] = useState(false)
|
||||||
|
|
||||||
|
// Use controlled state if provided, otherwise use internal state
|
||||||
|
const isControlled = controlledExpanded !== undefined
|
||||||
|
const expanded = isControlled ? controlledExpanded : internalExpanded
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (onToggle) {
|
||||||
|
onToggle()
|
||||||
|
}
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalExpanded(!internalExpanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
handleToggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleToggle}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className={cn(
|
||||||
|
'bg-white border border-border rounded-2xl p-6 cursor-pointer',
|
||||||
|
'transition-all duration-300 ease-in-out',
|
||||||
|
'hover:-translate-y-1 hover:shadow-xl',
|
||||||
|
'relative overflow-hidden',
|
||||||
|
'before:absolute before:inset-x-0 before:top-0 before:h-1',
|
||||||
|
'before:bg-gradient-to-r before:from-primary before:to-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header - Always visible */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div
|
||||||
|
data-testid="service-card-icon"
|
||||||
|
className="p-3 rounded-xl bg-primary/10 text-primary"
|
||||||
|
>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="expand-indicator"
|
||||||
|
className="p-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-xl font-bold text-foreground mb-2">{title}</h3>
|
||||||
|
|
||||||
|
{/* Summary - Always visible */}
|
||||||
|
<p className="text-muted-foreground">{summary}</p>
|
||||||
|
|
||||||
|
{/* Expandable content */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{expanded && (
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
variants={contentVariants}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="pt-4 mt-4 border-t border-border">
|
||||||
|
{/* Details */}
|
||||||
|
<p className="text-foreground mb-4">{details}</p>
|
||||||
|
|
||||||
|
{/* Capabilities */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="font-semibold text-foreground mb-2">What we deliver:</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{capabilities.map((capability, index) => (
|
||||||
|
<li key={index} className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="w-1.5 h-1.5 bg-primary rounded-full" />
|
||||||
|
{capability}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technologies */}
|
||||||
|
{technologies && technologies.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{technologies.map((tech, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-3 py-1 bg-primary/10 text-primary text-sm rounded-full font-medium"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
164
src/components/features/WorkflowStep.test.tsx
Normal file
164
src/components/features/WorkflowStep.test.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { WorkflowStep } from './WorkflowStep'
|
||||||
|
import type { WorkflowStepData } from '../../data/workflow'
|
||||||
|
|
||||||
|
const mockStep: WorkflowStepData = {
|
||||||
|
id: 1,
|
||||||
|
icon: 'Search',
|
||||||
|
title: 'Discovery',
|
||||||
|
tagline: 'Understand your goals',
|
||||||
|
description: 'Test description for the discovery phase',
|
||||||
|
deliverables: ['Item 1', 'Item 2', 'Item 3'],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WorkflowStep', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
step: mockStep,
|
||||||
|
isActive: false,
|
||||||
|
onClick: vi.fn(),
|
||||||
|
stepNumber: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders step title correctly', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Discovery')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders step tagline correctly', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Understand your goals')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders step icon', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} />)
|
||||||
|
// Icon should be present (Search icon from lucide-react)
|
||||||
|
expect(screen.getByTestId('workflow-step-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders step number', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} stepNumber={2} />)
|
||||||
|
expect(screen.getByText('Step 2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClick when clicked', async () => {
|
||||||
|
const handleClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<WorkflowStep {...defaultProps} onClick={handleClick} />)
|
||||||
|
|
||||||
|
const stepCard = screen.getByRole('button')
|
||||||
|
await user.click(stepCard)
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies active styles when isActive=true', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} isActive={true} />)
|
||||||
|
|
||||||
|
const stepCard = screen.getByRole('button')
|
||||||
|
// Active state: full opacity, elevated shadow, scale 1
|
||||||
|
expect(stepCard).toHaveClass('opacity-100')
|
||||||
|
expect(stepCard).toHaveClass('shadow-lg')
|
||||||
|
expect(stepCard).toHaveClass('scale-100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies inactive styles when isActive=false', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} isActive={false} />)
|
||||||
|
|
||||||
|
const stepCard = screen.getByRole('button')
|
||||||
|
// Inactive state: reduced opacity, scale 0.95
|
||||||
|
expect(stepCard).toHaveClass('opacity-70')
|
||||||
|
expect(stepCard).toHaveClass('scale-95')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows description when active', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} isActive={true} />)
|
||||||
|
expect(screen.getByText('Test description for the discovery phase')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows deliverables list when active', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} isActive={true} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Deliverables:')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Item 2')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Item 3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides description when inactive', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} isActive={false} />)
|
||||||
|
expect(screen.queryByText('Test description for the discovery phase')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides deliverables when inactive', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} isActive={false} />)
|
||||||
|
expect(screen.queryByText('Deliverables:')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is keyboard accessible', async () => {
|
||||||
|
const handleClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<WorkflowStep {...defaultProps} onClick={handleClick} />)
|
||||||
|
|
||||||
|
const stepCard = screen.getByRole('button')
|
||||||
|
stepCard.focus()
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports keyboard navigation with Space key', async () => {
|
||||||
|
const handleClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<WorkflowStep {...defaultProps} onClick={handleClick} />)
|
||||||
|
|
||||||
|
const stepCard = screen.getByRole('button')
|
||||||
|
stepCard.focus()
|
||||||
|
await user.keyboard(' ')
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has correct aria-expanded attribute based on isActive', () => {
|
||||||
|
const { rerender } = render(<WorkflowStep {...defaultProps} isActive={false} />)
|
||||||
|
|
||||||
|
const stepCard = screen.getByRole('button')
|
||||||
|
expect(stepCard).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
|
||||||
|
rerender(<WorkflowStep {...defaultProps} isActive={true} />)
|
||||||
|
expect(stepCard).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies primary border when active', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} isActive={true} />)
|
||||||
|
|
||||||
|
const stepCard = screen.getByRole('button')
|
||||||
|
expect(stepCard).toHaveClass('border-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies subtle border when inactive', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} isActive={false} />)
|
||||||
|
|
||||||
|
const stepCard = screen.getByRole('button')
|
||||||
|
expect(stepCard).toHaveClass('border-border')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies cursor-pointer style', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} />)
|
||||||
|
|
||||||
|
const stepCard = screen.getByRole('button')
|
||||||
|
expect(stepCard).toHaveClass('cursor-pointer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has transition classes for smooth animations', () => {
|
||||||
|
render(<WorkflowStep {...defaultProps} />)
|
||||||
|
|
||||||
|
const stepCard = screen.getByRole('button')
|
||||||
|
expect(stepCard).toHaveClass('transition-all')
|
||||||
|
expect(stepCard).toHaveClass('duration-300')
|
||||||
|
})
|
||||||
|
})
|
||||||
97
src/components/features/WorkflowStep.tsx
Normal file
97
src/components/features/WorkflowStep.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Search, Palette, Code, Rocket, type LucideIcon } from 'lucide-react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import type { WorkflowStepData } from '../../data/workflow'
|
||||||
|
|
||||||
|
const iconMap: Record<string, LucideIcon> = {
|
||||||
|
Search,
|
||||||
|
Palette,
|
||||||
|
Code,
|
||||||
|
Rocket,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowStepProps {
|
||||||
|
/** Step data */
|
||||||
|
step: WorkflowStepData
|
||||||
|
/** Whether this step is currently active */
|
||||||
|
isActive: boolean
|
||||||
|
/** Click handler to select this step */
|
||||||
|
onClick: () => void
|
||||||
|
/** Step number for display */
|
||||||
|
stepNumber: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowStepProps) {
|
||||||
|
const Icon = iconMap[step.icon] || Search
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-expanded={isActive}
|
||||||
|
className={cn(
|
||||||
|
'bg-white border rounded-2xl p-6 cursor-pointer transition-all duration-300 ease-in-out',
|
||||||
|
isActive
|
||||||
|
? 'opacity-100 shadow-lg scale-100 border-primary'
|
||||||
|
: 'opacity-70 shadow-md scale-95 border-border hover:opacity-90 hover:shadow-lg'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-2">
|
||||||
|
<div
|
||||||
|
data-testid="workflow-step-icon"
|
||||||
|
className={cn(
|
||||||
|
'p-3 rounded-xl transition-colors duration-300',
|
||||||
|
isActive ? 'bg-primary/10 text-primary' : 'bg-secondary/10 text-secondary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground font-medium">Step {stepNumber}</p>
|
||||||
|
<h3 className="text-xl font-bold text-foreground">{step.title}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tagline */}
|
||||||
|
<p className="text-muted-foreground mb-4">{step.tagline}</p>
|
||||||
|
|
||||||
|
{/* Expandable content */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="pt-4 border-t border-border">
|
||||||
|
<p className="text-foreground mb-4">{step.description}</p>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground mb-2">Deliverables:</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{step.deliverables.map((item, index) => (
|
||||||
|
<li key={index} className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="w-1.5 h-1.5 bg-primary rounded-full" />
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
src/components/features/index.ts
Normal file
3
src/components/features/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { WorkflowStep } from './WorkflowStep'
|
||||||
|
export { ExpandableServiceCard } from './ExpandableServiceCard'
|
||||||
|
export { BentoCard } from './BentoCard'
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { Header } from './Header'
|
import { Header } from './Header'
|
||||||
@ -167,8 +167,11 @@ describe('Header', () => {
|
|||||||
|
|
||||||
// Close menu
|
// Close menu
|
||||||
await user.click(screen.getByRole('button', { name: /close menu/i }))
|
await user.click(screen.getByRole('button', { name: /close menu/i }))
|
||||||
|
// Wait for the exit animation to complete
|
||||||
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole('navigation', { name: /mobile/i })).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation', { name: /mobile/i })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('mobile menu has same links as desktop nav', async () => {
|
it('mobile menu has same links as desktop nav', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|||||||
@ -67,9 +67,9 @@ export function Header({ links = defaultLinks }: HeaderProps) {
|
|||||||
<a
|
<a
|
||||||
href="#home"
|
href="#home"
|
||||||
onClick={(e) => handleLinkClick(e, '#home')}
|
onClick={(e) => handleLinkClick(e, '#home')}
|
||||||
className="flex items-center gap-2 text-xl font-bold text-primary"
|
className="flex items-center gap-2 text-xl font-bold text-primary transition-transform duration-300 hover:scale-105"
|
||||||
>
|
>
|
||||||
<Diamond className="h-5 w-5 text-accent" />
|
<Diamond className="h-5 w-5 text-accent transition-transform duration-300 hover:rotate-12" />
|
||||||
<span>Maverick</span>
|
<span>Maverick</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
150
src/components/sections/BentoGrid.test.tsx
Normal file
150
src/components/sections/BentoGrid.test.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { BentoGrid } from './BentoGrid'
|
||||||
|
|
||||||
|
// Mock IntersectionObserver
|
||||||
|
class MockIntersectionObserver {
|
||||||
|
callback: IntersectionObserverCallback
|
||||||
|
|
||||||
|
constructor(callback: IntersectionObserverCallback) {
|
||||||
|
this.callback = callback
|
||||||
|
// Immediately trigger as visible for testing
|
||||||
|
setTimeout(() => {
|
||||||
|
this.callback(
|
||||||
|
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||||
|
this as unknown as IntersectionObserver
|
||||||
|
)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BentoGrid', () => {
|
||||||
|
it('renders section header with default title', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
expect(screen.getByText('What We Build')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('End-to-end solutions tailored to your business needs')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders custom title when provided', () => {
|
||||||
|
render(<BentoGrid title="Our Services" subtitle="Custom solutions" />)
|
||||||
|
expect(screen.getByText('Our Services')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Custom solutions')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all 6 service cards', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
expect(screen.getByText('Web Applications')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Mobile Development')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('DevOps & Infrastructure')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('System Integration')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Security Solutions')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Technical Consulting')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies correct grid classes to container', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
const grid = screen.getByTestId('bento-grid-container')
|
||||||
|
expect(grid).toHaveClass('grid')
|
||||||
|
expect(grid).toHaveClass('grid-cols-1')
|
||||||
|
expect(grid).toHaveClass('lg:grid-cols-4')
|
||||||
|
expect(grid).toHaveClass('gap-6')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BentoGrid card positioning', () => {
|
||||||
|
it('renders 2 large cards', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
const largeCards = screen.getAllByTestId(/bento-card-lg/)
|
||||||
|
expect(largeCards).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 2 medium cards', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
const mediumCards = screen.getAllByTestId(/bento-card-md/)
|
||||||
|
expect(mediumCards).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 2 small cards', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
const smallCards = screen.getAllByTestId(/bento-card-sm/)
|
||||||
|
expect(smallCards).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies correct grid position classes to large cards', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
const largeCards = screen.getAllByTestId(/bento-card-lg/)
|
||||||
|
largeCards.forEach((card) => {
|
||||||
|
expect(card).toHaveClass('lg:col-span-2')
|
||||||
|
expect(card).toHaveClass('lg:row-span-2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies correct grid position classes to medium cards', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
const mediumCards = screen.getAllByTestId(/bento-card-md/)
|
||||||
|
mediumCards.forEach((card) => {
|
||||||
|
expect(card).toHaveClass('lg:col-span-2')
|
||||||
|
expect(card).toHaveClass('lg:row-span-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies correct grid position classes to small cards', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
const smallCards = screen.getAllByTestId(/bento-card-sm/)
|
||||||
|
smallCards.forEach((card) => {
|
||||||
|
expect(card).toHaveClass('lg:col-span-1')
|
||||||
|
expect(card).toHaveClass('lg:row-span-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BentoGrid responsiveness', () => {
|
||||||
|
it('has responsive column classes', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
const grid = screen.getByTestId('bento-grid-container')
|
||||||
|
// Mobile: 1 column, tablet: 2 columns, desktop: 4 columns
|
||||||
|
expect(grid).toHaveClass('grid-cols-1')
|
||||||
|
expect(grid).toHaveClass('md:grid-cols-2')
|
||||||
|
expect(grid).toHaveClass('lg:grid-cols-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cards have mobile override classes', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
// All cards should be full width on mobile
|
||||||
|
// Match only the card containers, not the icons
|
||||||
|
const largeCards = screen.getAllByTestId(/^bento-card-lg/)
|
||||||
|
const mediumCards = screen.getAllByTestId(/^bento-card-md/)
|
||||||
|
const smallCards = screen.getAllByTestId(/^bento-card-sm/)
|
||||||
|
const allCards = [...largeCards, ...mediumCards, ...smallCards]
|
||||||
|
allCards.forEach((card) => {
|
||||||
|
expect(card).toHaveClass('col-span-1')
|
||||||
|
expect(card).toHaveClass('row-span-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BentoGrid section structure', () => {
|
||||||
|
it('has correct section id for navigation', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
const section = screen.getByTestId('bento-grid-section')
|
||||||
|
expect(section).toHaveAttribute('id', 'services')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has section padding', () => {
|
||||||
|
render(<BentoGrid />)
|
||||||
|
const section = screen.getByTestId('bento-grid-section')
|
||||||
|
expect(section).toHaveClass('py-24')
|
||||||
|
})
|
||||||
|
})
|
||||||
73
src/components/sections/BentoGrid.tsx
Normal file
73
src/components/sections/BentoGrid.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Container } from '../layout/Container'
|
||||||
|
import { BentoCard } from '../features/BentoCard'
|
||||||
|
import { AnimatedSection } from '../ui/AnimatedSection'
|
||||||
|
import { services } from '../../data/services'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
interface BentoGridProps {
|
||||||
|
/** Optional section title */
|
||||||
|
title?: string
|
||||||
|
/** Optional section subtitle */
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeToResponsiveClasses = {
|
||||||
|
lg: 'lg:col-span-2 lg:row-span-2',
|
||||||
|
md: 'lg:col-span-2 lg:row-span-1',
|
||||||
|
sm: 'lg:col-span-1 lg:row-span-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BentoGrid({
|
||||||
|
title = 'What We Build',
|
||||||
|
subtitle = 'End-to-end solutions tailored to your business needs',
|
||||||
|
}: BentoGridProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="services"
|
||||||
|
data-testid="bento-grid-section"
|
||||||
|
className="py-24 bg-white"
|
||||||
|
>
|
||||||
|
<Container>
|
||||||
|
{/* Section Header */}
|
||||||
|
<AnimatedSection animation="fade-up" className="text-center mb-16">
|
||||||
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-foreground mb-4">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</AnimatedSection>
|
||||||
|
|
||||||
|
{/* Bento Grid */}
|
||||||
|
<AnimatedSection animation="fade-up" delay={200} stagger staggerDelay={100}>
|
||||||
|
<div
|
||||||
|
data-testid="bento-grid-container"
|
||||||
|
className={cn(
|
||||||
|
'grid gap-6',
|
||||||
|
'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||||
|
'auto-rows-min lg:grid-rows-[repeat(3,minmax(150px,auto))]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{services.map((service) => {
|
||||||
|
const size = service.size || 'md'
|
||||||
|
return (
|
||||||
|
<BentoCard
|
||||||
|
key={service.id}
|
||||||
|
service={service}
|
||||||
|
size={size}
|
||||||
|
data-testid={`bento-card-${size}-${service.id}`}
|
||||||
|
className={cn(
|
||||||
|
// Mobile: all cards are 1x1
|
||||||
|
'col-span-1 row-span-1',
|
||||||
|
// Desktop: apply size-specific classes
|
||||||
|
sizeToResponsiveClasses[size]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</AnimatedSection>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
240
src/components/sections/WorkflowCarousel.test.tsx
Normal file
240
src/components/sections/WorkflowCarousel.test.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen, act, fireEvent } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { WorkflowCarousel } from './WorkflowCarousel'
|
||||||
|
|
||||||
|
describe('WorkflowCarousel', () => {
|
||||||
|
it('renders section header', () => {
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('How We Work')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('From idea to production in four focused phases')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all 4 workflow steps', () => {
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Discovery')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Design')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Build')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Deploy')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders progress indicator dots', () => {
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
const dots = screen.getAllByRole('button', { name: /go to step/i })
|
||||||
|
expect(dots).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('first step is active by default', () => {
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
// The first step should show its description (expanded state)
|
||||||
|
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking a step makes it active', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
// Click the Design step
|
||||||
|
const designStep = screen.getByText('Design').closest('[role="button"]')
|
||||||
|
await user.click(designStep!)
|
||||||
|
|
||||||
|
// Design step description should now be visible
|
||||||
|
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking a dot navigates to that step', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
// Click the third dot (Build)
|
||||||
|
const dots = screen.getAllByRole('button', { name: /go to step/i })
|
||||||
|
await user.click(dots[2])
|
||||||
|
|
||||||
|
// Build step description should now be visible
|
||||||
|
expect(screen.getByText(/Agile development/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has correct aria-current on active dot', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
const dots = screen.getAllByRole('button', { name: /go to step/i })
|
||||||
|
|
||||||
|
// First dot should have aria-current="step" initially
|
||||||
|
expect(dots[0]).toHaveAttribute('aria-current', 'step')
|
||||||
|
expect(dots[1]).not.toHaveAttribute('aria-current')
|
||||||
|
|
||||||
|
// Click second dot
|
||||||
|
await user.click(dots[1])
|
||||||
|
|
||||||
|
// Second dot should now have aria-current
|
||||||
|
expect(dots[0]).not.toHaveAttribute('aria-current')
|
||||||
|
expect(dots[1]).toHaveAttribute('aria-current', 'step')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Auto-advance', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-advances after interval', () => {
|
||||||
|
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
|
||||||
|
|
||||||
|
// Initially first step is active
|
||||||
|
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Advance time
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Second step should now be active (Design)
|
||||||
|
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pauses on hover', () => {
|
||||||
|
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
|
||||||
|
|
||||||
|
const carousel = screen.getByTestId('workflow-carousel')
|
||||||
|
|
||||||
|
// Hover over carousel
|
||||||
|
fireEvent.mouseEnter(carousel)
|
||||||
|
|
||||||
|
// Advance time
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(6000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should still be on first step (paused)
|
||||||
|
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/Architecture planning/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resumes after mouse leaves', () => {
|
||||||
|
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
|
||||||
|
|
||||||
|
const carousel = screen.getByTestId('workflow-carousel')
|
||||||
|
|
||||||
|
// Hover then unhover
|
||||||
|
fireEvent.mouseEnter(carousel)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3000)
|
||||||
|
})
|
||||||
|
fireEvent.mouseLeave(carousel)
|
||||||
|
|
||||||
|
// Should still be on first step
|
||||||
|
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Now advance time - should move to next step
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loops back to first step after last', () => {
|
||||||
|
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
|
||||||
|
|
||||||
|
// Advance through all 4 steps
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000) // Step 2
|
||||||
|
})
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000) // Step 3
|
||||||
|
})
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000) // Step 4
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should be on Deploy (step 4)
|
||||||
|
expect(screen.getByText(/Production deployment/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Advance one more time - should loop back to step 1
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not auto-advance when autoAdvance=false', () => {
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should still be on first step
|
||||||
|
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/Architecture planning/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Keyboard navigation', () => {
|
||||||
|
it('right arrow advances to next step', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
const carousel = screen.getByTestId('workflow-carousel')
|
||||||
|
carousel.focus()
|
||||||
|
|
||||||
|
await user.keyboard('{ArrowRight}')
|
||||||
|
|
||||||
|
// Second step should now be active
|
||||||
|
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('left arrow goes to previous step', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
const carousel = screen.getByTestId('workflow-carousel')
|
||||||
|
carousel.focus()
|
||||||
|
|
||||||
|
// Go to second step first
|
||||||
|
await user.keyboard('{ArrowRight}')
|
||||||
|
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Go back
|
||||||
|
await user.keyboard('{ArrowLeft}')
|
||||||
|
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps around at end boundary', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
const carousel = screen.getByTestId('workflow-carousel')
|
||||||
|
carousel.focus()
|
||||||
|
|
||||||
|
// Go to last step
|
||||||
|
await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}')
|
||||||
|
expect(screen.getByText(/Production deployment/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Arrow right should wrap to first step
|
||||||
|
await user.keyboard('{ArrowRight}')
|
||||||
|
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps around at start boundary', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<WorkflowCarousel autoAdvance={false} />)
|
||||||
|
|
||||||
|
const carousel = screen.getByTestId('workflow-carousel')
|
||||||
|
carousel.focus()
|
||||||
|
|
||||||
|
// Arrow left from first step should go to last step
|
||||||
|
await user.keyboard('{ArrowLeft}')
|
||||||
|
expect(screen.getByText(/Production deployment/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
114
src/components/sections/WorkflowCarousel.tsx
Normal file
114
src/components/sections/WorkflowCarousel.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Container } from '../layout/Container'
|
||||||
|
import { WorkflowStep } from '../features/WorkflowStep'
|
||||||
|
import { workflowSteps } from '../../data/workflow'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
interface WorkflowCarouselProps {
|
||||||
|
/** Enable auto-advance */
|
||||||
|
autoAdvance?: boolean
|
||||||
|
/** Auto-advance interval in ms */
|
||||||
|
autoAdvanceInterval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowCarousel({
|
||||||
|
autoAdvance = true,
|
||||||
|
autoAdvanceInterval = 5000,
|
||||||
|
}: WorkflowCarouselProps) {
|
||||||
|
const [activeStep, setActiveStep] = useState(0)
|
||||||
|
const [isPaused, setIsPaused] = useState(false)
|
||||||
|
|
||||||
|
// Auto-advance effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoAdvance || isPaused) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setActiveStep((current) => (current + 1) % workflowSteps.length)
|
||||||
|
}, autoAdvanceInterval)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [autoAdvance, autoAdvanceInterval, isPaused])
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault()
|
||||||
|
setActiveStep((current) => (current + 1) % workflowSteps.length)
|
||||||
|
} else if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault()
|
||||||
|
setActiveStep((current) => (current - 1 + workflowSteps.length) % workflowSteps.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="process"
|
||||||
|
className="py-24 bg-slate-900"
|
||||||
|
data-testid="workflow-carousel"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onMouseEnter={() => setIsPaused(true)}
|
||||||
|
onMouseLeave={() => setIsPaused(false)}
|
||||||
|
>
|
||||||
|
<Container>
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-4">
|
||||||
|
How We Work
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-slate-300 max-w-2xl mx-auto">
|
||||||
|
From idea to production in four focused phases
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Carousel */}
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||||
|
{workflowSteps.map((step, index) => (
|
||||||
|
<WorkflowStep
|
||||||
|
key={step.id}
|
||||||
|
step={step}
|
||||||
|
stepNumber={index + 1}
|
||||||
|
isActive={activeStep === index}
|
||||||
|
onClick={() => setActiveStep(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Dots */}
|
||||||
|
<div className="flex justify-center items-center gap-3">
|
||||||
|
{workflowSteps.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setActiveStep(index)}
|
||||||
|
aria-label={`Go to step ${index + 1}`}
|
||||||
|
aria-current={activeStep === index ? 'step' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full transition-all duration-300',
|
||||||
|
activeStep === index
|
||||||
|
? 'w-4 h-4 bg-primary'
|
||||||
|
: 'w-3 h-3 bg-slate-600 hover:bg-slate-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar (for auto-advance) */}
|
||||||
|
{autoAdvance && !isPaused && (
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<div className="w-48 h-1 bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
key={activeStep}
|
||||||
|
className="h-full bg-primary origin-left"
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
animate={{ scaleX: 1 }}
|
||||||
|
transition={{ duration: autoAdvanceInterval / 1000, ease: 'linear' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
export { Hero } from './Hero'
|
export { Hero } from './Hero'
|
||||||
export { Stats } from './Stats'
|
export { Stats } from './Stats'
|
||||||
export { Contact } from './Contact'
|
export { Contact } from './Contact'
|
||||||
|
export { WorkflowCarousel } from './WorkflowCarousel'
|
||||||
|
export { BentoGrid } from './BentoGrid'
|
||||||
|
|||||||
73
src/data/services.ts
Normal file
73
src/data/services.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
export interface BentoCardService {
|
||||||
|
id: string
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
details: string
|
||||||
|
technologies: string[]
|
||||||
|
size?: 'lg' | 'md' | 'sm'
|
||||||
|
gridPosition?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const services: BentoCardService[] = [
|
||||||
|
{
|
||||||
|
id: 'web-apps',
|
||||||
|
icon: 'Globe',
|
||||||
|
title: 'Web Applications',
|
||||||
|
summary: 'Full-stack solutions that scale',
|
||||||
|
details: 'Modern web applications built with React, Node.js, and cloud infrastructure. From MVPs to enterprise platforms.',
|
||||||
|
technologies: ['React', 'Node.js', 'PostgreSQL', 'AWS'],
|
||||||
|
size: 'lg',
|
||||||
|
gridPosition: 'col-span-2 row-span-2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mobile',
|
||||||
|
icon: 'Smartphone',
|
||||||
|
title: 'Mobile Development',
|
||||||
|
summary: 'Native and cross-platform apps',
|
||||||
|
details: 'Beautiful, performant mobile applications for iOS and Android using React Native and native technologies.',
|
||||||
|
technologies: ['React Native', 'Swift', 'Kotlin'],
|
||||||
|
size: 'md',
|
||||||
|
gridPosition: 'col-span-2 row-span-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'devops',
|
||||||
|
icon: 'Cloud',
|
||||||
|
title: 'DevOps & Infrastructure',
|
||||||
|
summary: 'Reliable, scalable systems',
|
||||||
|
details: 'CI/CD pipelines, containerization, and cloud deployment for high-availability systems.',
|
||||||
|
technologies: ['Docker', 'AWS', 'Terraform', 'Kubernetes'],
|
||||||
|
size: 'lg',
|
||||||
|
gridPosition: 'col-span-2 row-span-2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integration',
|
||||||
|
icon: 'Plug',
|
||||||
|
title: 'System Integration',
|
||||||
|
summary: 'Connect your systems',
|
||||||
|
details: 'API integrations, data synchronization, and workflow automation.',
|
||||||
|
technologies: ['REST', 'GraphQL', 'Webhooks'],
|
||||||
|
size: 'sm',
|
||||||
|
gridPosition: 'col-span-1 row-span-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
icon: 'Shield',
|
||||||
|
title: 'Security Solutions',
|
||||||
|
summary: 'Protect your assets',
|
||||||
|
details: 'Authentication, authorization, security audits, and compliance.',
|
||||||
|
technologies: ['OAuth', 'JWT', 'OWASP'],
|
||||||
|
size: 'sm',
|
||||||
|
gridPosition: 'col-span-1 row-span-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'consulting',
|
||||||
|
icon: 'Lightbulb',
|
||||||
|
title: 'Technical Consulting',
|
||||||
|
summary: 'Strategic technology guidance',
|
||||||
|
details: 'Architecture reviews, technology strategy, and team mentoring for your projects.',
|
||||||
|
technologies: ['Architecture', 'Strategy', 'Mentoring'],
|
||||||
|
size: 'md',
|
||||||
|
gridPosition: 'col-span-2 row-span-1',
|
||||||
|
},
|
||||||
|
]
|
||||||
43
src/data/workflow.ts
Normal file
43
src/data/workflow.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
export interface WorkflowStepData {
|
||||||
|
id: number
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
tagline: string
|
||||||
|
description: string
|
||||||
|
deliverables: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workflowSteps: WorkflowStepData[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
icon: 'Search',
|
||||||
|
title: 'Discovery',
|
||||||
|
tagline: 'Understand your goals',
|
||||||
|
description: 'We dive deep into your business requirements, user needs, and technical constraints.',
|
||||||
|
deliverables: ['Requirements document', 'Technical roadmap', 'Project timeline'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
icon: 'Palette',
|
||||||
|
title: 'Design',
|
||||||
|
tagline: 'Blueprint for success',
|
||||||
|
description: 'Architecture planning and UI/UX design that prioritizes user experience.',
|
||||||
|
deliverables: ['System architecture', 'UI mockups', 'Database schema'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
icon: 'Code',
|
||||||
|
title: 'Build',
|
||||||
|
tagline: 'Code with precision',
|
||||||
|
description: 'Agile development with regular demos and continuous integration.',
|
||||||
|
deliverables: ['Working software', 'Test coverage', 'Documentation'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
icon: 'Rocket',
|
||||||
|
title: 'Deploy',
|
||||||
|
tagline: 'Ship with confidence',
|
||||||
|
description: 'Production deployment with monitoring, CI/CD, and ongoing support.',
|
||||||
|
deliverables: ['Production environment', 'Monitoring setup', 'Support plan'],
|
||||||
|
},
|
||||||
|
]
|
||||||
@ -74,3 +74,24 @@
|
|||||||
@apply bg-white/95 backdrop-blur-md;
|
@apply bg-white/95 backdrop-blur-md;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility animations */
|
||||||
|
@layer utilities {
|
||||||
|
/* Subtle pulse for stats */
|
||||||
|
.animate-stat-pulse {
|
||||||
|
animation: stat-pulse 2s ease-in-out 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stat-pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.02); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo hover rotation */
|
||||||
|
.hover-rotate {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.hover-rotate:hover {
|
||||||
|
transform: rotate(12deg) scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -44,3 +44,57 @@ export const staggerContainer: Variants = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AnimationVariant = keyof typeof animationVariants
|
export type AnimationVariant = keyof typeof animationVariants
|
||||||
|
|
||||||
|
// Shared transition presets
|
||||||
|
export const springTransition = {
|
||||||
|
type: 'spring' as const,
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const easeTransition = {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'easeOut' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common hover effects
|
||||||
|
export const hoverLift = {
|
||||||
|
y: -5,
|
||||||
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hoverScale = {
|
||||||
|
scale: 1.05,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon animation variants
|
||||||
|
export const iconVariants = {
|
||||||
|
rest: { scale: 1, rotate: 0 },
|
||||||
|
hover: {
|
||||||
|
scale: 1.1,
|
||||||
|
rotate: [0, -5, 5, 0],
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge hover animation
|
||||||
|
export const badgeVariants = {
|
||||||
|
rest: { y: 0 },
|
||||||
|
hover: {
|
||||||
|
y: -3,
|
||||||
|
transition: springTransition,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card hover animation
|
||||||
|
export const cardHoverVariants = {
|
||||||
|
rest: {
|
||||||
|
y: 0,
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
y: -5,
|
||||||
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||||
|
transition: easeTransition,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user