Fix WorkflowCarousel layout and add placeholder image system
Restructure WorkflowCarousel into tab headers + detail panel to eliminate blank space in inactive cards and jarring height jumps on step change. Detail panel uses a grid overlay crossfade for smooth transitions. Add PlaceholderImage component and integrate into Hero, BentoCard, WorkflowCarousel detail panel, and Contact sections. Include AI generation prompts (PROMPTS.md) for future image creation with Nano Banana 3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
import { Globe, Smartphone, Cloud, Plug, Shield, Lightbulb, type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { PlaceholderImage } from '../ui/PlaceholderImage'
|
||||
import type { BentoCardService } from '../../data/services'
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
@ -57,6 +58,7 @@ export function BentoCard({
|
||||
summary={service.summary}
|
||||
details={service.details}
|
||||
technologies={service.technologies}
|
||||
image={service.image}
|
||||
/>
|
||||
)}
|
||||
{size === 'md' && (
|
||||
@ -65,6 +67,7 @@ export function BentoCard({
|
||||
title={service.title}
|
||||
summary={service.summary}
|
||||
details={service.details}
|
||||
image={service.image}
|
||||
/>
|
||||
)}
|
||||
{size === 'sm' && (
|
||||
@ -80,11 +83,24 @@ interface LargeLayoutProps {
|
||||
summary: string
|
||||
details: string
|
||||
technologies: string[]
|
||||
image?: string
|
||||
}
|
||||
|
||||
function LargeLayout({ icon: Icon, title, summary, details, technologies }: LargeLayoutProps) {
|
||||
function LargeLayout({ icon: Icon, title, summary, details, technologies, image }: LargeLayoutProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{image && (
|
||||
<div className="mb-4 -mx-8 -mt-8">
|
||||
<PlaceholderImage
|
||||
src={image}
|
||||
alt={`${title} illustration`}
|
||||
label={title}
|
||||
aspectRatio="4:3"
|
||||
className="w-full rounded-t-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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"
|
||||
@ -115,9 +131,10 @@ interface MediumLayoutProps {
|
||||
title: string
|
||||
summary: string
|
||||
details: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
function MediumLayout({ icon: Icon, title, summary, details }: MediumLayoutProps) {
|
||||
function MediumLayout({ icon: Icon, title, summary, details, image }: MediumLayoutProps) {
|
||||
return (
|
||||
<div className="flex gap-4 items-start">
|
||||
<div
|
||||
@ -132,6 +149,18 @@ function MediumLayout({ icon: Icon, title, summary, details }: MediumLayoutProps
|
||||
<p className="text-muted-foreground text-sm mb-2">{summary}</p>
|
||||
<p className="text-foreground text-sm">{details}</p>
|
||||
</div>
|
||||
|
||||
{image && (
|
||||
<div className="hidden sm:block w-24 flex-shrink-0">
|
||||
<PlaceholderImage
|
||||
src={image}
|
||||
alt={`${title} illustration`}
|
||||
label={title}
|
||||
aspectRatio="3:2"
|
||||
className="w-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -73,30 +73,6 @@ describe('WorkflowStep', () => {
|
||||
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()
|
||||
@ -123,16 +99,6 @@ describe('WorkflowStep', () => {
|
||||
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} />)
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
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'
|
||||
@ -37,7 +36,6 @@ export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowSt
|
||||
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
|
||||
@ -63,35 +61,7 @@ export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowSt
|
||||
</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>
|
||||
<p className="text-muted-foreground">{step.tagline}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { Container } from '../layout/Container'
|
||||
import { Card } from '../ui/Card'
|
||||
import { Button } from '../ui/Button'
|
||||
import { Input } from '../ui/Input'
|
||||
import { PlaceholderImage } from '../ui/PlaceholderImage'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface ContactMethod {
|
||||
@ -147,6 +148,16 @@ export function Contact({ onSubmit }: ContactProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<PlaceholderImage
|
||||
src="/images/contact-collab.webp"
|
||||
alt="Collaboration illustration"
|
||||
label="Collaboration"
|
||||
aspectRatio="4:3"
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Form */}
|
||||
|
||||
@ -2,6 +2,7 @@ import { motion } from 'framer-motion'
|
||||
import { Zap, Shield, Layers, Cloud, TrendingUp, Sparkles } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Button } from '../ui/Button'
|
||||
import { PlaceholderImage } from '../ui/PlaceholderImage'
|
||||
import { Container } from '../layout/Container'
|
||||
|
||||
interface HeroProps {
|
||||
@ -89,29 +90,43 @@ export function Hero({ headline, subheadline }: HeroProps) {
|
||||
See How We Work
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Capability badges — below text on all sizes */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 gap-4"
|
||||
>
|
||||
{capabilities.map(({ label, icon: Icon }) => (
|
||||
<div
|
||||
key={label}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-4',
|
||||
'bg-white rounded-xl shadow-sm',
|
||||
'hover:-translate-y-1 hover:shadow-md',
|
||||
'transition-all duration-300'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Capability badges */}
|
||||
{/* Hero illustration — visible on lg screens */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 gap-4"
|
||||
className="hidden lg:block"
|
||||
>
|
||||
{capabilities.map(({ label, icon: Icon }) => (
|
||||
<div
|
||||
key={label}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-4',
|
||||
'bg-white rounded-xl shadow-sm',
|
||||
'hover:-translate-y-1 hover:shadow-md',
|
||||
'transition-all duration-300'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<PlaceholderImage
|
||||
src="/images/hero-dashboard.webp"
|
||||
alt="Software dashboard illustration"
|
||||
label="Dashboard"
|
||||
aspectRatio="16:10"
|
||||
className="w-full rounded-2xl shadow-lg"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</Container>
|
||||
|
||||
@ -14,7 +14,8 @@ describe('WorkflowCarousel', () => {
|
||||
it('renders all 4 workflow steps', () => {
|
||||
render(<WorkflowCarousel autoAdvance={false} />)
|
||||
|
||||
expect(screen.getByText('Discovery')).toBeInTheDocument()
|
||||
// Discovery appears in both the tab and detail panel
|
||||
expect(screen.getAllByText('Discovery').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('Design')).toBeInTheDocument()
|
||||
expect(screen.getByText('Build')).toBeInTheDocument()
|
||||
expect(screen.getByText('Deploy')).toBeInTheDocument()
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Container } from '../layout/Container'
|
||||
import { WorkflowStep } from '../features/WorkflowStep'
|
||||
import { PlaceholderImage } from '../ui/PlaceholderImage'
|
||||
import { workflowSteps } from '../../data/workflow'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
@ -63,8 +64,8 @@ export function WorkflowCarousel({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Carousel */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
{/* Step Tabs */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{workflowSteps.map((step, index) => (
|
||||
<WorkflowStep
|
||||
key={step.id}
|
||||
@ -76,6 +77,53 @@ export function WorkflowCarousel({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail Panel — grid overlay keeps both panels in the same cell during crossfade */}
|
||||
<div className="grid mt-8 mb-12 [&>*]:col-start-1 [&>*]:row-start-1">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={activeStep}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 1 }}
|
||||
transition={{ duration: 1.0, ease: 'easeInOut' }}
|
||||
className="bg-white rounded-2xl p-8 border border-slate-200"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold text-foreground mb-3">
|
||||
{workflowSteps[activeStep].title}
|
||||
</h3>
|
||||
<p className="text-foreground mb-4">
|
||||
{workflowSteps[activeStep].description}
|
||||
</p>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground mb-2">Deliverables:</p>
|
||||
<ul className="space-y-1">
|
||||
{workflowSteps[activeStep].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>
|
||||
{workflowSteps[activeStep].image && (
|
||||
<div className="md:w-64 lg:w-80 flex-shrink-0">
|
||||
<PlaceholderImage
|
||||
src={workflowSteps[activeStep].image}
|
||||
alt={`${workflowSteps[activeStep].title} illustration`}
|
||||
label={workflowSteps[activeStep].title}
|
||||
className="w-full rounded-xl"
|
||||
aspectRatio="1:1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Progress Dots */}
|
||||
<div className="flex justify-center items-center gap-3">
|
||||
{workflowSteps.map((_, index) => (
|
||||
|
||||
69
src/components/ui/PlaceholderImage.tsx
Normal file
69
src/components/ui/PlaceholderImage.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
interface PlaceholderImageProps {
|
||||
/** Image source path — renders <img> if provided and the file exists, otherwise a placeholder SVG */
|
||||
src?: string
|
||||
/** Alt text for the image */
|
||||
alt?: string
|
||||
/** Label displayed on the placeholder */
|
||||
label?: string
|
||||
/** Additional CSS classes */
|
||||
className?: string
|
||||
/** Aspect ratio for the placeholder (e.g. "16:10", "4:3", "1:1") */
|
||||
aspectRatio?: string
|
||||
}
|
||||
|
||||
const aspectRatioMap: Record<string, string> = {
|
||||
'16:10': 'aspect-[16/10]',
|
||||
'4:3': 'aspect-[4/3]',
|
||||
'3:2': 'aspect-[3/2]',
|
||||
'1:1': 'aspect-square',
|
||||
}
|
||||
|
||||
export function PlaceholderImage({
|
||||
src,
|
||||
alt = '',
|
||||
label = 'Image',
|
||||
className,
|
||||
aspectRatio = '4:3',
|
||||
}: PlaceholderImageProps) {
|
||||
if (src) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn('object-cover', aspectRatioMap[aspectRatio], className)}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={alt || label}
|
||||
className={cn(
|
||||
'bg-slate-100 border border-slate-200 rounded-xl flex items-center justify-center',
|
||||
aspectRatioMap[aspectRatio],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-center px-4">
|
||||
<svg
|
||||
className="w-12 h-12 text-slate-300 mx-auto mb-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-slate-400">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -7,6 +7,7 @@ export interface BentoCardService {
|
||||
technologies: string[]
|
||||
size?: 'lg' | 'md' | 'sm'
|
||||
gridPosition?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export const services: BentoCardService[] = [
|
||||
@ -19,6 +20,7 @@ export const services: BentoCardService[] = [
|
||||
technologies: ['React', 'Node.js', 'PostgreSQL', 'AWS'],
|
||||
size: 'lg',
|
||||
gridPosition: 'col-span-2 row-span-2',
|
||||
image: '/images/service-web-apps.webp',
|
||||
},
|
||||
{
|
||||
id: 'mobile',
|
||||
@ -29,6 +31,7 @@ export const services: BentoCardService[] = [
|
||||
technologies: ['React Native', 'Swift', 'Kotlin'],
|
||||
size: 'md',
|
||||
gridPosition: 'col-span-2 row-span-1',
|
||||
image: '/images/service-mobile.webp',
|
||||
},
|
||||
{
|
||||
id: 'devops',
|
||||
@ -39,6 +42,7 @@ export const services: BentoCardService[] = [
|
||||
technologies: ['Docker', 'AWS', 'Terraform', 'Kubernetes'],
|
||||
size: 'lg',
|
||||
gridPosition: 'col-span-2 row-span-2',
|
||||
image: '/images/service-devops.webp',
|
||||
},
|
||||
{
|
||||
id: 'integration',
|
||||
|
||||
@ -5,6 +5,7 @@ export interface WorkflowStepData {
|
||||
tagline: string
|
||||
description: string
|
||||
deliverables: string[]
|
||||
image?: string
|
||||
}
|
||||
|
||||
export const workflowSteps: WorkflowStepData[] = [
|
||||
@ -15,6 +16,7 @@ export const workflowSteps: WorkflowStepData[] = [
|
||||
tagline: 'Understand your goals',
|
||||
description: 'We dive deep into your business requirements, user needs, and technical constraints.',
|
||||
deliverables: ['Requirements document', 'Technical roadmap', 'Project timeline'],
|
||||
image: '/images/workflow-discovery.webp',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@ -23,6 +25,7 @@ export const workflowSteps: WorkflowStepData[] = [
|
||||
tagline: 'Blueprint for success',
|
||||
description: 'Architecture planning and UI/UX design that prioritizes user experience.',
|
||||
deliverables: ['System architecture', 'UI mockups', 'Database schema'],
|
||||
image: '/images/workflow-design.webp',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
@ -31,6 +34,7 @@ export const workflowSteps: WorkflowStepData[] = [
|
||||
tagline: 'Code with precision',
|
||||
description: 'Agile development with regular demos and continuous integration.',
|
||||
deliverables: ['Working software', 'Test coverage', 'Documentation'],
|
||||
image: '/images/workflow-build.webp',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
@ -39,5 +43,6 @@ export const workflowSteps: WorkflowStepData[] = [
|
||||
tagline: 'Ship with confidence',
|
||||
description: 'Production deployment with monitoring, CI/CD, and ongoing support.',
|
||||
deliverables: ['Production environment', 'Monitoring setup', 'Support plan'],
|
||||
image: '/images/workflow-deploy.webp',
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user