랜딩 페이지: 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:
Yun Chan 2026-04-05 23:55:13 +09:00
parent eb83682269
commit 5892125740
45 changed files with 6225 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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"
/>
)
}

View 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>
)
}

View 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>
)
}

View 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
}