랜딩 페이지: D3RO 브랜드 프로모션 사이트 + GitHub Pages 배포
- Vite + React 19 + Tailwind CSS - 4가지 디자인 레퍼런스 통합 (CRT 스크린, 크로스헤어, 노이즈, 인스트루먼트) - 섹션 9개: Hero, Features, HowItWorks, Privacy, Pricing, FAQ, CTA, Header, Footer - 재사용 컴포넌트 8개 (Badge, Container, InstrumentCard, GlowButton 등) - i18n 10개 국어 (en/ko/ja/zh/es/fr/de/pt/ru/vi) - GitHub Pages 자동 배포 워크플로우
This commit is contained in:
parent
eb83682269
commit
5892125740
45 changed files with 6225 additions and 0 deletions
26
site/src/components/Badge.tsx
Normal file
26
site/src/components/Badge.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { Led } from './Led'
|
||||
|
||||
interface BadgeProps {
|
||||
children: ReactNode
|
||||
led?: boolean
|
||||
ledColor?: 'amber' | 'green' | 'red'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Badge({ children, led = false, ledColor = 'amber', className = '' }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-2 px-3 py-1
|
||||
font-mono text-nano uppercase tracking-widest
|
||||
text-neutral-400 bg-surface-700/50 border border-white/[0.06]
|
||||
rounded-sm select-none
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{led && <Led color={ledColor} size="sm" />}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
14
site/src/components/Container.tsx
Normal file
14
site/src/components/Container.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
interface ContainerProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Container({ children, className = '' }: ContainerProps) {
|
||||
return (
|
||||
<div className={`mx-auto w-full max-w-7xl px-5 md:px-8 lg:px-12 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
site/src/components/Crosshair.tsx
Normal file
14
site/src/components/Crosshair.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
interface CrosshairProps {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Crosshair({ className = '', children }: CrosshairProps) {
|
||||
return (
|
||||
<div className={`crosshair-box ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
site/src/components/DataPoint.tsx
Normal file
22
site/src/components/DataPoint.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
interface DataPointProps {
|
||||
label: string
|
||||
value: string
|
||||
unit?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataPoint({ label, value, unit, className = '' }: DataPointProps) {
|
||||
return (
|
||||
<div className={`flex flex-col ${className}`}>
|
||||
<span className="font-mono text-nano uppercase tracking-widest text-neutral-500 mb-1">
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-display text-2xl md:text-3xl font-bold text-neutral-100 tracking-tight">
|
||||
{value}
|
||||
{unit && (
|
||||
<span className="text-sm font-mono text-neutral-500 ml-1">{unit}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
site/src/components/GlowButton.tsx
Normal file
45
site/src/components/GlowButton.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
interface GlowButtonProps {
|
||||
children: ReactNode
|
||||
href?: string
|
||||
variant?: 'primary' | 'secondary'
|
||||
size?: 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GlowButton({
|
||||
children,
|
||||
href,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
}: GlowButtonProps) {
|
||||
const base = 'glow-btn inline-flex items-center justify-center font-mono text-sm font-medium tracking-wide uppercase rounded-panel select-none'
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-brand-amber text-white hover:bg-brand-amber-light',
|
||||
secondary: 'bg-surface-500 text-neutral-200 border border-white/[0.08] hover:border-brand-amber/30',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
md: 'px-6 py-3 text-xs',
|
||||
lg: 'px-8 py-4 text-sm',
|
||||
}
|
||||
|
||||
const cls = `${base} ${variants[variant]} ${sizes[size]} ${className}`
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} className={cls}>
|
||||
<span className="relative z-10">{children}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={cls}>
|
||||
<span className="relative z-10">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
26
site/src/components/InstrumentCard.tsx
Normal file
26
site/src/components/InstrumentCard.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
interface InstrumentCardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
hover?: boolean
|
||||
}
|
||||
|
||||
export function InstrumentCard({ children, className = '', hover = true }: InstrumentCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative noise-texture
|
||||
bg-surface-600 border border-white/[0.06]
|
||||
rounded-card shadow-chassis
|
||||
p-5 md:p-6
|
||||
${hover ? 'transition-all duration-250 hover:border-brand-amber/20 hover:shadow-glow-sm' : ''}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<div className="relative z-10">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
site/src/components/LanguageSwitcher.tsx
Normal file
82
site/src/components/LanguageSwitcher.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useI18n, LOCALES } from '../i18n'
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, setLocale } = useI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const current = LOCALES.find((l) => l.code === locale)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 font-mono text-nano uppercase tracking-widest text-neutral-400 hover:text-white border border-white/[0.06] rounded-panel transition-colors"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<GlobeIcon />
|
||||
{current?.label ?? 'EN'}
|
||||
<ChevronIcon open={open} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-40 py-1 bg-surface-700 border border-white/[0.08] rounded-card shadow-lg z-50 max-h-80 overflow-y-auto">
|
||||
{LOCALES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => {
|
||||
setLocale(l.code)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 font-mono text-xs flex items-center justify-between transition-colors ${
|
||||
l.code === locale
|
||||
? 'text-brand-amber bg-brand-amber/[0.08]'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-surface-600'
|
||||
}`}
|
||||
>
|
||||
<span>{l.nativeName}</span>
|
||||
<span className="text-nano text-neutral-600 uppercase">{l.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GlobeIcon() {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
37
site/src/components/Led.tsx
Normal file
37
site/src/components/Led.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
interface LedProps {
|
||||
color?: 'amber' | 'green' | 'red'
|
||||
pulse?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
amber: {
|
||||
bg: 'bg-brand-amber',
|
||||
shadow: 'shadow-[0_0_6px_rgba(242,91,41,0.8)]',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-emerald-400',
|
||||
shadow: 'shadow-[0_0_6px_rgba(52,211,153,0.8)]',
|
||||
},
|
||||
red: {
|
||||
bg: 'bg-red-500',
|
||||
shadow: 'shadow-[0_0_6px_rgba(239,68,68,0.8)]',
|
||||
},
|
||||
} as const
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'w-1.5 h-1.5',
|
||||
md: 'w-2 h-2',
|
||||
} as const
|
||||
|
||||
export function Led({ color = 'amber', pulse = false, size = 'sm', className = '' }: LedProps) {
|
||||
const c = colorMap[color]
|
||||
const s = sizeMap[size]
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full ${c.bg} ${c.shadow} ${s} ${pulse ? 'animate-glow-pulse' : ''} ${className}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
27
site/src/components/SectionHeader.tsx
Normal file
27
site/src/components/SectionHeader.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
interface SectionHeaderProps {
|
||||
index: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SectionHeader({ index, title, subtitle, className = '' }: SectionHeaderProps) {
|
||||
return (
|
||||
<div className={`mb-12 md:mb-16 ${className}`}>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<span className="font-mono text-nano uppercase tracking-widest text-brand-amber">
|
||||
{index}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-white/[0.06]" />
|
||||
</div>
|
||||
<h2 className="font-display text-display font-bold text-neutral-50 tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-3 max-w-xl text-base text-neutral-400 leading-relaxed">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
site/src/components/WaveBars.tsx
Normal file
47
site/src/components/WaveBars.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const BAR_COUNT = 9
|
||||
const COS_WEIGHTS = Array.from({ length: BAR_COUNT }, (_, i) =>
|
||||
Math.cos((i - 4) * (Math.PI / 9)),
|
||||
)
|
||||
|
||||
interface WaveBarsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function WaveBars({ className = '' }: WaveBarsProps) {
|
||||
const barsRef = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let frame: number
|
||||
let t = 0
|
||||
|
||||
const animate = () => {
|
||||
t += 0.05
|
||||
barsRef.current.forEach((bar, i) => {
|
||||
if (!bar) return
|
||||
const base = COS_WEIGHTS[i]
|
||||
const wave = Math.sin(t + i * 0.4) * 0.5 + 0.5
|
||||
const h = 4 + base * wave * 28
|
||||
bar.style.height = `${h}px`
|
||||
})
|
||||
frame = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(animate)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-[3px] h-10 ${className}`} aria-hidden="true">
|
||||
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => { barsRef.current[i] = el }}
|
||||
className="w-[3px] rounded-full bg-brand-amber/60"
|
||||
style={{ height: 4, transition: 'height 60ms ease-out' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
site/src/components/useReveal.ts
Normal file
31
site/src/components/useReveal.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Intersection Observer hook for reveal-on-scroll animations.
|
||||
* Returns a ref to attach to the element that should animate in.
|
||||
*/
|
||||
export function useReveal<T extends HTMLElement = HTMLDivElement>(
|
||||
threshold = 0.15,
|
||||
) {
|
||||
const ref = useRef<T>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
el.classList.add('visible')
|
||||
observer.unobserve(el)
|
||||
}
|
||||
},
|
||||
{ threshold },
|
||||
)
|
||||
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [threshold])
|
||||
|
||||
return ref
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue