Skip to content

Commit aad6ed5

Browse files
committed
feat: implement hero section with responsive layout and tests
1 parent 9d1c736 commit aad6ed5

13 files changed

Lines changed: 688 additions & 1 deletion
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from 'react'
2+
3+
interface SyntaxHighlighterProps {
4+
language: string
5+
style: any
6+
children: React.ReactNode
7+
}
8+
9+
export const Prism = ({ children, language, style }: SyntaxHighlighterProps) => (
10+
<pre className={`language-${language}`}>{children}</pre>
11+
)
12+
13+
export const vscDarkPlus = {}

jest.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const config: Config = {
1010
testEnvironment: 'jest-environment-jsdom',
1111
moduleNameMapper: {
1212
'^@/(.*)$': '<rootDir>/src/$1',
13+
'^react-syntax-highlighter$': '<rootDir>/__mocks__/react-syntax-highlighter.tsx',
14+
'^react-syntax-highlighter/dist/esm/styles/prism$': '<rootDir>/__mocks__/react-syntax-highlighter.tsx',
1315
},
1416
testMatch: [
1517
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
@@ -35,6 +37,9 @@ const config: Config = {
3537
tsconfig: 'tsconfig.json',
3638
}],
3739
},
40+
transformIgnorePatterns: [
41+
'/node_modules/(?!(react-syntax-highlighter)/)'
42+
],
3843
};
3944

4045
export default createJestConfig(config);

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@
1414
"test:coverage": "jest --coverage"
1515
},
1616
"dependencies": {
17+
"@types/react-syntax-highlighter": "^15.5.13",
1718
"class-variance-authority": "^0.7.1",
1819
"clsx": "^2.1.1",
20+
"framer-motion": "^12.12.2",
1921
"lucide-react": "^0.511.0",
2022
"next": "14.1.0",
23+
"next-themes": "^0.4.6",
2124
"react": "^18",
2225
"react-dom": "^18",
26+
"react-syntax-highlighter": "^15.6.1",
2327
"tailwind-merge": "^3.3.0"
2428
},
2529
"devDependencies": {

public/avatar.png

1.52 MB
Loading

src/app/page.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,21 @@ import {
1313
Text,
1414
} from "@/components/ui";
1515
import { ThemeToggle } from '../components/ui/ThemeToggle'
16+
import Hero from '@/components/ui/Hero'
1617

1718
export default function Home() {
1819
return (
1920
<div className="min-h-screen bg-background text-text">
21+
<Hero
22+
headline="Senior Full-Stack Developer & Problem Solver"
23+
subtitle="20+ years crafting scalable web applications with React, Node.js, and modern architectures"
24+
location="Istanbul, Turkey • Remote Worldwide"
25+
avatarSrc="/avatar.png"
26+
avatarAlt="Professional headshot"
27+
ctaPrimary={{ label: 'View Projects', href: '/projects' }}
28+
ctaSecondary={{ label: 'Contact Me', href: '/contact' }}
29+
/>
30+
2031
<Section variant="surface" spacing="lg" className="border-b border-accent/20">
2132
<div className="container mx-auto flex justify-between items-center">
2233
<Heading as="h1" size="h2" className="text-accent">UI Component Showcase</Heading>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
2+
jest.mock('react-syntax-highlighter')
3+
jest.mock('next/dynamic', () => ({
4+
__esModule: true,
5+
default: () => {
6+
return function MockComponent(props: any) {
7+
return <div>{props.label}</div>
8+
}
9+
}
10+
}))
11+
12+
import ComponentPlayground from './ComponentPlayground'
13+
14+
describe('ComponentPlayground', () => {
15+
const mockComponent = {
16+
name: 'Button',
17+
variants: ['primary', 'secondary'],
18+
props: {
19+
label: { type: 'string', default: 'Click me' },
20+
disabled: { type: 'boolean', default: false }
21+
}
22+
}
23+
24+
it('renders component preview with default props', async () => {
25+
render(<ComponentPlayground component={mockComponent} />)
26+
const preview = screen.getByTestId('component-preview')
27+
await waitFor(() => {
28+
expect(preview).toContainElement(screen.getByText('Click me'))
29+
})
30+
})
31+
32+
it('allows prop modification through controls', async () => {
33+
render(<ComponentPlayground component={mockComponent} />)
34+
const labelInput = screen.getByLabelText(/label/i)
35+
fireEvent.change(labelInput, { target: { value: 'New Label' } })
36+
expect(labelInput).toHaveValue('New Label')
37+
})
38+
39+
it('displays code example for current configuration', () => {
40+
render(<ComponentPlayground component={mockComponent} />)
41+
expect(screen.getByTestId('code-example')).toBeInTheDocument()
42+
})
43+
44+
it('allows switching between variants', () => {
45+
render(<ComponentPlayground component={mockComponent} />)
46+
const variantSelect = screen.getByLabelText(/variant/i)
47+
fireEvent.change(variantSelect, { target: { value: 'secondary' } })
48+
expect(variantSelect).toHaveValue('secondary')
49+
})
50+
51+
it('renders the actual component in preview', async () => {
52+
render(<ComponentPlayground component={mockComponent} />)
53+
await waitFor(() => {
54+
expect(screen.getByText('Click me')).toBeInTheDocument()
55+
})
56+
})
57+
58+
it('updates preview when props change', async () => {
59+
render(<ComponentPlayground component={mockComponent} />)
60+
const labelInput = screen.getByLabelText(/label/i)
61+
fireEvent.change(labelInput, { target: { value: 'New Label' } })
62+
await waitFor(() => {
63+
expect(screen.getByText('New Label')).toBeInTheDocument()
64+
})
65+
})
66+
})
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { useState } from 'react'
2+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
3+
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
4+
import dynamic from 'next/dynamic'
5+
6+
interface ComponentProp {
7+
type: string
8+
default: any
9+
}
10+
11+
interface ComponentDefinition {
12+
name: string
13+
variants: string[]
14+
props: Record<string, ComponentProp>
15+
}
16+
17+
interface ComponentPlaygroundProps {
18+
component: ComponentDefinition
19+
}
20+
21+
const ComponentPlayground = ({ component }: ComponentPlaygroundProps) => {
22+
const [selectedVariant, setSelectedVariant] = useState(component.variants[0])
23+
const [props, setProps] = useState<Record<string, any>>(
24+
Object.entries(component.props).reduce((acc, [key, value]) => ({
25+
...acc,
26+
[key]: value.default
27+
}), {})
28+
)
29+
30+
const handlePropChange = (propName: string, value: any) => {
31+
setProps(prev => ({ ...prev, [propName]: value }))
32+
}
33+
34+
const generateCodeExample = () => {
35+
const propsString = Object.entries(props)
36+
.map(([key, value]) => `${key}="${value}"`)
37+
.join(' ')
38+
return `<${component.name} variant="${selectedVariant}" ${propsString} />`
39+
}
40+
41+
const DynamicComponent = dynamic(() => import(`@/components/ui/${component.name}`), {
42+
loading: () => <div>Loading component...</div>,
43+
ssr: false
44+
})
45+
46+
return (
47+
<div className="space-y-4">
48+
<div data-testid="component-preview" className="p-4 border rounded-lg">
49+
<h3 className="text-lg font-semibold mb-2">Preview</h3>
50+
<DynamicComponent {...props} variant={selectedVariant} />
51+
</div>
52+
53+
<div className="space-y-2">
54+
<label className="block">
55+
Variant:
56+
<select
57+
value={selectedVariant}
58+
onChange={(e) => setSelectedVariant(e.target.value)}
59+
className="ml-2 p-1 border rounded"
60+
>
61+
{component.variants.map(variant => (
62+
<option key={variant} value={variant}>{variant}</option>
63+
))}
64+
</select>
65+
</label>
66+
67+
{Object.entries(component.props).map(([propName, propDef]) => (
68+
<label key={propName} className="block">
69+
{propName}:
70+
{propDef.type === 'boolean' ? (
71+
<input
72+
type="checkbox"
73+
checked={props[propName]}
74+
onChange={(e) => handlePropChange(propName, e.target.checked)}
75+
className="ml-2"
76+
/>
77+
) : (
78+
<input
79+
type="text"
80+
value={props[propName]}
81+
onChange={(e) => handlePropChange(propName, e.target.value)}
82+
className="ml-2 p-1 border rounded"
83+
/>
84+
)}
85+
</label>
86+
))}
87+
</div>
88+
89+
<div data-testid="code-example" className="mt-4">
90+
<h3 className="text-lg font-semibold mb-2">Code Example</h3>
91+
<SyntaxHighlighter language="tsx" style={vscDarkPlus}>
92+
{generateCodeExample()}
93+
</SyntaxHighlighter>
94+
</div>
95+
</div>
96+
)
97+
}
98+
99+
export default ComponentPlayground

src/components/ui/Hero.tsx

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"use client"
2+
3+
import { FC } from 'react'
4+
import Link from 'next/link'
5+
import Image from 'next/image'
6+
import { motion } from 'framer-motion'
7+
8+
interface CTAButton {
9+
label: string
10+
href: string
11+
}
12+
13+
interface HeroProps {
14+
headline: string
15+
subtitle: string
16+
location: string
17+
avatarSrc?: string
18+
avatarAlt?: string
19+
ctaPrimary?: CTAButton
20+
ctaSecondary?: CTAButton
21+
}
22+
23+
const Hero: FC<HeroProps> = ({
24+
headline,
25+
subtitle,
26+
location,
27+
avatarSrc,
28+
avatarAlt,
29+
ctaPrimary,
30+
ctaSecondary
31+
}) => {
32+
return (
33+
<section
34+
data-testid="hero-container"
35+
className="min-h-screen md:min-h-screen flex items-center justify-center px-4 py-16 md:py-24"
36+
>
37+
<div className="max-w-4xl mx-auto text-center space-y-8">
38+
{avatarSrc && (
39+
<motion.div
40+
initial={{ opacity: 0, y: 20 }}
41+
animate={{ opacity: 1, y: 0 }}
42+
transition={{ duration: 0.5 }}
43+
className="mb-8"
44+
>
45+
<Image
46+
src={avatarSrc}
47+
alt={avatarAlt || 'Professional headshot'}
48+
width={150}
49+
height={150}
50+
className="rounded-full mx-auto"
51+
priority
52+
/>
53+
</motion.div>
54+
)}
55+
56+
<motion.h1
57+
initial={{ opacity: 0, y: 20 }}
58+
animate={{ opacity: 1, y: 0 }}
59+
transition={{ duration: 0.5, delay: 0.2 }}
60+
className="text-4xl md:text-5xl lg:text-6xl font-bold mb-4"
61+
role="heading"
62+
aria-level={1}
63+
>
64+
{headline}
65+
</motion.h1>
66+
67+
<motion.div
68+
initial={{ opacity: 0, y: 20 }}
69+
animate={{ opacity: 1, y: 0 }}
70+
transition={{ duration: 0.5, delay: 0.4 }}
71+
className="flex items-center justify-center gap-2 mb-8"
72+
>
73+
<span className="text-lg md:text-xl text-muted">{subtitle}</span>
74+
<span className="px-3 py-1 bg-surface rounded-full text-sm font-medium">
75+
{location}
76+
</span>
77+
</motion.div>
78+
79+
{(ctaPrimary || ctaSecondary) && (
80+
<motion.div
81+
initial={{ opacity: 0, y: 20 }}
82+
animate={{ opacity: 1, y: 0 }}
83+
transition={{ duration: 0.5, delay: 0.6 }}
84+
className="flex flex-col sm:flex-row gap-4 justify-center"
85+
>
86+
{ctaPrimary && (
87+
<Link
88+
href={ctaPrimary.href}
89+
className="px-6 py-3 bg-accent text-white rounded-lg hover:bg-accent/90 transition-colors"
90+
>
91+
{ctaPrimary.label}
92+
</Link>
93+
)}
94+
{ctaSecondary && (
95+
<Link
96+
href={ctaSecondary.href}
97+
className="px-6 py-3 border border-accent text-accent rounded-lg hover:bg-accent/10 transition-colors"
98+
>
99+
{ctaSecondary.label}
100+
</Link>
101+
)}
102+
</motion.div>
103+
)}
104+
105+
<motion.div
106+
initial={{ opacity: 0 }}
107+
animate={{ opacity: 1 }}
108+
transition={{ duration: 0.5, delay: 0.8 }}
109+
data-testid="scroll-indicator"
110+
className="absolute bottom-8 left-1/2 transform -translate-x-1/2"
111+
>
112+
<div className="w-6 h-10 border-2 border-accent rounded-full flex justify-center">
113+
<motion.div
114+
animate={{
115+
y: [0, 12, 0],
116+
}}
117+
transition={{
118+
duration: 1.5,
119+
repeat: Infinity,
120+
repeatType: "loop",
121+
}}
122+
className="w-1 h-3 bg-accent rounded-full mt-2"
123+
/>
124+
</div>
125+
</motion.div>
126+
</div>
127+
</section>
128+
)
129+
}
130+
131+
export default Hero

0 commit comments

Comments
 (0)