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:
37
public/images/PROMPTS.md
Normal file
37
public/images/PROMPTS.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# AI Image Generation Prompts (Nano Banana 3)
|
||||||
|
|
||||||
|
## 1. Hero Illustration (`hero-dashboard.webp`)
|
||||||
|
|
||||||
|
Isometric 3D illustration of a modern software dashboard floating above a desk workspace. Clean minimal style with soft blue (#2563eb) and amber (#f59e0b) accent colors on a light grey background. Shows a laptop screen with abstract UI components — charts, cards, and navigation elements. Subtle floating code snippets and cloud icons surround it. Professional tech company hero image style. No text. No people. Sharp vector-like rendering with soft shadows. 16:10 aspect ratio.
|
||||||
|
|
||||||
|
## 2. Web Applications Service (`service-web-apps.webp`)
|
||||||
|
|
||||||
|
Clean flat illustration of a modern web application interface. Shows a browser window with a dashboard containing analytics charts, a sidebar navigation, and data cards. Colour palette: deep blue (#2563eb) primary with white and light grey. Minimal geometric style, no text, no people. Soft drop shadows. 4:3 aspect ratio.
|
||||||
|
|
||||||
|
## 3. Mobile Development Service (`service-mobile.webp`)
|
||||||
|
|
||||||
|
Flat illustration of two smartphones side by side (iOS and Android) displaying a modern mobile app interface. Screens show a clean onboarding flow with abstract UI elements. Blue (#2563eb) accent colour, white backgrounds, rounded corners. Minimal style, no text, no people. 3:2 aspect ratio.
|
||||||
|
|
||||||
|
## 4. DevOps & Infrastructure Service (`service-devops.webp`)
|
||||||
|
|
||||||
|
Isometric illustration of cloud infrastructure: server racks, Docker containers as stacked blue cubes, CI/CD pipeline shown as connected conveyor belts with checkmarks. Kubernetes wheel logo subtly integrated. Blue (#2563eb) and slate grey colour scheme on white background. Technical but clean, no text, no people. 4:3 aspect ratio.
|
||||||
|
|
||||||
|
## 5. Contact Illustration (`contact-collab.webp`)
|
||||||
|
|
||||||
|
Warm minimal illustration of two abstract figures collaborating over a shared digital workspace. One figure gestures toward a floating screen with wireframes. Soft blue (#2563eb) and amber (#f59e0b) highlights on a light background. Friendly, approachable, professional. No realistic faces — abstract/geometric people. 4:3 aspect ratio.
|
||||||
|
|
||||||
|
## 6. Workflow: Discovery (`workflow-discovery.webp`)
|
||||||
|
|
||||||
|
Minimal illustration of a magnifying glass examining a blueprint/roadmap document. Abstract sticky notes and a checklist float nearby. Blue (#2563eb) tones with amber (#f59e0b) accent on dark slate (#1e293b) background. Clean flat style, no text, no people. Square 1:1 aspect ratio.
|
||||||
|
|
||||||
|
## 7. Workflow: Design (`workflow-design.webp`)
|
||||||
|
|
||||||
|
Minimal illustration of a design workspace: a colour palette, wireframe sketches, and a pen tool cursor. Geometric UI component shapes float in an organised arrangement. Blue (#2563eb) tones with amber accent on dark slate (#1e293b) background. Flat vector style, no text, no people. Square 1:1 aspect ratio.
|
||||||
|
|
||||||
|
## 8. Workflow: Build (`workflow-build.webp`)
|
||||||
|
|
||||||
|
Minimal illustration of code editor windows stacked at angles, showing abstract coloured syntax lines. A gear/cog icon and test-tube icon float nearby representing testing and automation. Blue (#2563eb) on dark slate (#1e293b). Clean flat style, no text, no people. Square 1:1 aspect ratio.
|
||||||
|
|
||||||
|
## 9. Workflow: Deploy (`workflow-deploy.webp`)
|
||||||
|
|
||||||
|
Minimal illustration of a rocket launching from a laptop screen toward abstract cloud shapes. Uptime chart and checkmark shield float alongside. Blue (#2563eb) with amber (#f59e0b) rocket flame on dark slate (#1e293b). Flat style, no text, no people. Square 1:1 aspect ratio.
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { Globe, Smartphone, Cloud, Plug, Shield, Lightbulb, type LucideIcon } from 'lucide-react'
|
import { Globe, Smartphone, Cloud, Plug, Shield, Lightbulb, type LucideIcon } from 'lucide-react'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
|
import { PlaceholderImage } from '../ui/PlaceholderImage'
|
||||||
import type { BentoCardService } from '../../data/services'
|
import type { BentoCardService } from '../../data/services'
|
||||||
|
|
||||||
const iconMap: Record<string, LucideIcon> = {
|
const iconMap: Record<string, LucideIcon> = {
|
||||||
@ -57,6 +58,7 @@ export function BentoCard({
|
|||||||
summary={service.summary}
|
summary={service.summary}
|
||||||
details={service.details}
|
details={service.details}
|
||||||
technologies={service.technologies}
|
technologies={service.technologies}
|
||||||
|
image={service.image}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{size === 'md' && (
|
{size === 'md' && (
|
||||||
@ -65,6 +67,7 @@ export function BentoCard({
|
|||||||
title={service.title}
|
title={service.title}
|
||||||
summary={service.summary}
|
summary={service.summary}
|
||||||
details={service.details}
|
details={service.details}
|
||||||
|
image={service.image}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{size === 'sm' && (
|
{size === 'sm' && (
|
||||||
@ -80,11 +83,24 @@ interface LargeLayoutProps {
|
|||||||
summary: string
|
summary: string
|
||||||
details: string
|
details: string
|
||||||
technologies: 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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<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
|
<div
|
||||||
data-testid="bento-card-icon"
|
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"
|
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
|
title: string
|
||||||
summary: string
|
summary: string
|
||||||
details: string
|
details: string
|
||||||
|
image?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediumLayout({ icon: Icon, title, summary, details }: MediumLayoutProps) {
|
function MediumLayout({ icon: Icon, title, summary, details, image }: MediumLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 items-start">
|
<div className="flex gap-4 items-start">
|
||||||
<div
|
<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-muted-foreground text-sm mb-2">{summary}</p>
|
||||||
<p className="text-foreground text-sm">{details}</p>
|
<p className="text-foreground text-sm">{details}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,30 +73,6 @@ describe('WorkflowStep', () => {
|
|||||||
expect(stepCard).toHaveClass('scale-95')
|
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 () => {
|
it('is keyboard accessible', async () => {
|
||||||
const handleClick = vi.fn()
|
const handleClick = vi.fn()
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
@ -123,16 +99,6 @@ describe('WorkflowStep', () => {
|
|||||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
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', () => {
|
it('applies primary border when active', () => {
|
||||||
render(<WorkflowStep {...defaultProps} isActive={true} />)
|
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 { Search, Palette, Code, Rocket, type LucideIcon } from 'lucide-react'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import type { WorkflowStepData } from '../../data/workflow'
|
import type { WorkflowStepData } from '../../data/workflow'
|
||||||
@ -37,7 +36,6 @@ export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowSt
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
aria-expanded={isActive}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-white border rounded-2xl p-6 cursor-pointer transition-all duration-300 ease-in-out',
|
'bg-white border rounded-2xl p-6 cursor-pointer transition-all duration-300 ease-in-out',
|
||||||
isActive
|
isActive
|
||||||
@ -63,35 +61,7 @@ export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowSt
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tagline */}
|
{/* Tagline */}
|
||||||
<p className="text-muted-foreground mb-4">{step.tagline}</p>
|
<p className="text-muted-foreground">{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Container } from '../layout/Container'
|
|||||||
import { Card } from '../ui/Card'
|
import { Card } from '../ui/Card'
|
||||||
import { Button } from '../ui/Button'
|
import { Button } from '../ui/Button'
|
||||||
import { Input } from '../ui/Input'
|
import { Input } from '../ui/Input'
|
||||||
|
import { PlaceholderImage } from '../ui/PlaceholderImage'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
interface ContactMethod {
|
interface ContactMethod {
|
||||||
@ -147,6 +148,16 @@ export function Contact({ onSubmit }: ContactProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Contact Form */}
|
{/* Contact Form */}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { motion } from 'framer-motion'
|
|||||||
import { Zap, Shield, Layers, Cloud, TrendingUp, Sparkles } from 'lucide-react'
|
import { Zap, Shield, Layers, Cloud, TrendingUp, Sparkles } from 'lucide-react'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import { Button } from '../ui/Button'
|
import { Button } from '../ui/Button'
|
||||||
|
import { PlaceholderImage } from '../ui/PlaceholderImage'
|
||||||
import { Container } from '../layout/Container'
|
import { Container } from '../layout/Container'
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
@ -89,29 +90,43 @@ export function Hero({ headline, subheadline }: HeroProps) {
|
|||||||
See How We Work
|
See How We Work
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Capability badges */}
|
{/* Hero illustration — visible on lg screens */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="grid grid-cols-2 sm:grid-cols-3 gap-4"
|
className="hidden lg:block"
|
||||||
>
|
>
|
||||||
{capabilities.map(({ label, icon: Icon }) => (
|
<PlaceholderImage
|
||||||
<div
|
src="/images/hero-dashboard.webp"
|
||||||
key={label}
|
alt="Software dashboard illustration"
|
||||||
className={cn(
|
label="Dashboard"
|
||||||
'flex items-center gap-3 p-4',
|
aspectRatio="16:10"
|
||||||
'bg-white rounded-xl shadow-sm',
|
className="w-full rounded-2xl shadow-lg"
|
||||||
'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>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -14,7 +14,8 @@ describe('WorkflowCarousel', () => {
|
|||||||
it('renders all 4 workflow steps', () => {
|
it('renders all 4 workflow steps', () => {
|
||||||
render(<WorkflowCarousel autoAdvance={false} />)
|
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('Design')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Build')).toBeInTheDocument()
|
expect(screen.getByText('Build')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Deploy')).toBeInTheDocument()
|
expect(screen.getByText('Deploy')).toBeInTheDocument()
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Container } from '../layout/Container'
|
import { Container } from '../layout/Container'
|
||||||
import { WorkflowStep } from '../features/WorkflowStep'
|
import { WorkflowStep } from '../features/WorkflowStep'
|
||||||
|
import { PlaceholderImage } from '../ui/PlaceholderImage'
|
||||||
import { workflowSteps } from '../../data/workflow'
|
import { workflowSteps } from '../../data/workflow'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
@ -63,8 +64,8 @@ export function WorkflowCarousel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Carousel */}
|
{/* Step Tabs */}
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{workflowSteps.map((step, index) => (
|
{workflowSteps.map((step, index) => (
|
||||||
<WorkflowStep
|
<WorkflowStep
|
||||||
key={step.id}
|
key={step.id}
|
||||||
@ -76,6 +77,53 @@ export function WorkflowCarousel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Progress Dots */}
|
||||||
<div className="flex justify-center items-center gap-3">
|
<div className="flex justify-center items-center gap-3">
|
||||||
{workflowSteps.map((_, index) => (
|
{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[]
|
technologies: string[]
|
||||||
size?: 'lg' | 'md' | 'sm'
|
size?: 'lg' | 'md' | 'sm'
|
||||||
gridPosition?: string
|
gridPosition?: string
|
||||||
|
image?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const services: BentoCardService[] = [
|
export const services: BentoCardService[] = [
|
||||||
@ -19,6 +20,7 @@ export const services: BentoCardService[] = [
|
|||||||
technologies: ['React', 'Node.js', 'PostgreSQL', 'AWS'],
|
technologies: ['React', 'Node.js', 'PostgreSQL', 'AWS'],
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
gridPosition: 'col-span-2 row-span-2',
|
gridPosition: 'col-span-2 row-span-2',
|
||||||
|
image: '/images/service-web-apps.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mobile',
|
id: 'mobile',
|
||||||
@ -29,6 +31,7 @@ export const services: BentoCardService[] = [
|
|||||||
technologies: ['React Native', 'Swift', 'Kotlin'],
|
technologies: ['React Native', 'Swift', 'Kotlin'],
|
||||||
size: 'md',
|
size: 'md',
|
||||||
gridPosition: 'col-span-2 row-span-1',
|
gridPosition: 'col-span-2 row-span-1',
|
||||||
|
image: '/images/service-mobile.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'devops',
|
id: 'devops',
|
||||||
@ -39,6 +42,7 @@ export const services: BentoCardService[] = [
|
|||||||
technologies: ['Docker', 'AWS', 'Terraform', 'Kubernetes'],
|
technologies: ['Docker', 'AWS', 'Terraform', 'Kubernetes'],
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
gridPosition: 'col-span-2 row-span-2',
|
gridPosition: 'col-span-2 row-span-2',
|
||||||
|
image: '/images/service-devops.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'integration',
|
id: 'integration',
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export interface WorkflowStepData {
|
|||||||
tagline: string
|
tagline: string
|
||||||
description: string
|
description: string
|
||||||
deliverables: string[]
|
deliverables: string[]
|
||||||
|
image?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const workflowSteps: WorkflowStepData[] = [
|
export const workflowSteps: WorkflowStepData[] = [
|
||||||
@ -15,6 +16,7 @@ export const workflowSteps: WorkflowStepData[] = [
|
|||||||
tagline: 'Understand your goals',
|
tagline: 'Understand your goals',
|
||||||
description: 'We dive deep into your business requirements, user needs, and technical constraints.',
|
description: 'We dive deep into your business requirements, user needs, and technical constraints.',
|
||||||
deliverables: ['Requirements document', 'Technical roadmap', 'Project timeline'],
|
deliverables: ['Requirements document', 'Technical roadmap', 'Project timeline'],
|
||||||
|
image: '/images/workflow-discovery.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@ -23,6 +25,7 @@ export const workflowSteps: WorkflowStepData[] = [
|
|||||||
tagline: 'Blueprint for success',
|
tagline: 'Blueprint for success',
|
||||||
description: 'Architecture planning and UI/UX design that prioritizes user experience.',
|
description: 'Architecture planning and UI/UX design that prioritizes user experience.',
|
||||||
deliverables: ['System architecture', 'UI mockups', 'Database schema'],
|
deliverables: ['System architecture', 'UI mockups', 'Database schema'],
|
||||||
|
image: '/images/workflow-design.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
@ -31,6 +34,7 @@ export const workflowSteps: WorkflowStepData[] = [
|
|||||||
tagline: 'Code with precision',
|
tagline: 'Code with precision',
|
||||||
description: 'Agile development with regular demos and continuous integration.',
|
description: 'Agile development with regular demos and continuous integration.',
|
||||||
deliverables: ['Working software', 'Test coverage', 'Documentation'],
|
deliverables: ['Working software', 'Test coverage', 'Documentation'],
|
||||||
|
image: '/images/workflow-build.webp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
@ -39,5 +43,6 @@ export const workflowSteps: WorkflowStepData[] = [
|
|||||||
tagline: 'Ship with confidence',
|
tagline: 'Ship with confidence',
|
||||||
description: 'Production deployment with monitoring, CI/CD, and ongoing support.',
|
description: 'Production deployment with monitoring, CI/CD, and ongoing support.',
|
||||||
deliverables: ['Production environment', 'Monitoring setup', 'Support plan'],
|
deliverables: ['Production environment', 'Monitoring setup', 'Support plan'],
|
||||||
|
image: '/images/workflow-deploy.webp',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user