feat(site): implement dynamic client OS negotiation for Hero and CTA download buttons
Some checks failed
deploy-site-windows / deploy-win (push) Waiting to run
deploy-site / deploy (push) Failing after 24s

This commit is contained in:
Yun Chan 2026-08-20 22:57:38 +09:00
parent 4865387d7f
commit 55d03baf6b
5 changed files with 280 additions and 16 deletions

View file

@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
interface GlowButtonProps {
children: ReactNode
href?: string
download?: string
variant?: 'primary' | 'secondary'
size?: 'md' | 'lg'
className?: string
@ -11,6 +12,7 @@ interface GlowButtonProps {
export function GlowButton({
children,
href,
download,
variant = 'primary',
size = 'md',
className = '',
@ -31,7 +33,7 @@ export function GlowButton({
if (href) {
return (
<a href={href} className={cls}>
<a href={href} download={download} className={cls}>
<span className="relative z-10">{children}</span>
</a>
)
@ -43,3 +45,4 @@ export function GlowButton({
</button>
)
}

View file

@ -0,0 +1,105 @@
import { useState, useEffect } from 'react'
export type ClientOSType = 'android' | 'ios' | 'windows' | 'macos' | 'linux'
export interface ClientOSInfo {
os: ClientOSType
osName: string
isMobile: boolean
downloadUrl: string
downloadFilename: string
badgeText: string
buttonLabelKo: string
buttonLabelEn: string
subtextKo: string
subtextEn: string
}
export const OS_CONFIGS: Record<ClientOSType, ClientOSInfo> = {
android: {
os: 'android',
osName: 'Android',
isMobile: true,
downloadUrl: 'https://d3ro.chanpaca.net/releases/1.0.0/d3ro-voice-v1.0.0-signed.apk',
downloadFilename: 'd3ro-voice-v1.0.0-signed.apk',
badgeText: 'ANDROID APK v1.0.0',
buttonLabelKo: 'Android용 무료 다운로드 (APK)',
buttonLabelEn: 'Download for Android (APK)',
subtextKo: 'v1.0.0 · 공식 서명 APK (47.5MB) · Google Play 호환',
subtextEn: 'v1.0.0 · Official Signed APK (47.5MB) · Play Protected',
},
ios: {
os: 'ios',
osName: 'iOS',
isMobile: true,
downloadUrl: '#download',
downloadFilename: '',
badgeText: 'iOS TESTFLIGHT / PWA',
buttonLabelKo: 'iOS 앱 / TestFlight 시작',
buttonLabelEn: 'Download for iOS (TestFlight)',
subtextKo: 'v1.0.0 · iPhone & iPad 지원 · PWA 즉시 실행',
subtextEn: 'v1.0.0 · iPhone & iPad Supported · Instant PWA',
},
windows: {
os: 'windows',
osName: 'Windows',
isMobile: false,
downloadUrl: 'https://d3ro.chanpaca.net/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe',
downloadFilename: 'D3RO-Voice-Setup-1.0.0-x64.exe',
badgeText: 'WINDOWS x64 v1.0.0',
buttonLabelKo: 'Windows용 무료 다운로드',
buttonLabelEn: 'Download for Windows (x64)',
subtextKo: 'v1.0.0 · x64 공식 인스톨러 (102MB) · 온디바이스',
subtextEn: 'v1.0.0 · x64 Official Setup (102MB) · On-Device',
},
macos: {
os: 'macos',
osName: 'macOS',
isMobile: false,
downloadUrl: '#download',
downloadFilename: '',
badgeText: 'MACOS UNIVERSAL',
buttonLabelKo: 'macOS용 무료 다운로드',
buttonLabelEn: 'Download for macOS (Universal)',
subtextKo: 'v1.0.0 · Apple Silicon (M1/M2/M3/M4) & Intel 지원',
subtextEn: 'v1.0.0 · Apple Silicon & Intel Universal',
},
linux: {
os: 'linux',
osName: 'Linux',
isMobile: false,
downloadUrl: '#download',
downloadFilename: '',
badgeText: 'LINUX APPIMAGE / DEB',
buttonLabelKo: 'Linux용 무료 다운로드',
buttonLabelEn: 'Download for Linux (AppImage)',
subtextKo: 'v1.0.0 · Ubuntu / Debian / Arch 지원',
subtextEn: 'v1.0.0 · Ubuntu / Debian / Arch Supported',
},
}
export function useClientOS(): ClientOSInfo {
const [osInfo, setOsInfo] = useState<ClientOSInfo>(OS_CONFIGS.windows)
useEffect(() => {
if (typeof window === 'undefined' || !navigator) return
const ua = navigator.userAgent || ''
const platform = (navigator as any).userAgentData?.platform || navigator.platform || ''
if (/android/i.test(ua)) {
setOsInfo(OS_CONFIGS.android)
} else if (/iphone|ipad|ipod/i.test(ua) || (platform === 'MacIntel' && navigator.maxTouchPoints > 1)) {
setOsInfo(OS_CONFIGS.ios)
} else if (/macintosh|mac os x/i.test(ua) || /mac/i.test(platform)) {
setOsInfo(OS_CONFIGS.macos)
} else if (/linux/i.test(ua) || /linux/i.test(platform)) {
setOsInfo(OS_CONFIGS.linux)
} else if (/windows|win32|win64/i.test(ua) || /win/i.test(platform)) {
setOsInfo(OS_CONFIGS.windows)
} else {
setOsInfo(OS_CONFIGS.windows)
}
}, [])
return osInfo
}

View file

@ -3,9 +3,11 @@ import { Crosshair } from '../components/Crosshair'
import { WaveBars } from '../components/WaveBars'
import { GlowButton } from '../components/GlowButton'
import { useI18n } from '../i18n'
import { useClientOS } from '../hooks/useClientOS'
export function CTA() {
const { t } = useI18n()
const { t, locale } = useI18n()
const clientOS = useClientOS()
return (
<section className="relative py-28 md:py-36 overflow-hidden">
@ -47,15 +49,23 @@ export function CTA() {
</div>
</div>
<div>
<div className="flex flex-col items-center gap-3">
<GlowButton
href="#download"
href={clientOS.downloadUrl}
download={clientOS.downloadFilename || undefined}
variant="primary"
size="lg"
className="shadow-glow-md hover:shadow-glow-lg"
>
{t.cta.downloadBtn}
{locale === 'ko' ? clientOS.buttonLabelKo : clientOS.buttonLabelEn}
</GlowButton>
<div className="flex items-center gap-2 font-mono text-xs text-neutral-400">
<span>{locale === 'ko' ? clientOS.subtextKo : clientOS.subtextEn}</span>
<span>·</span>
<a href="#download" className="underline hover:text-brand-amber transition-colors">
{locale === 'ko' ? '다른 OS 다운로드' : 'Other Platforms'}
</a>
</div>
</div>
<p className="font-mono text-nano text-neutral-500 mt-8 uppercase tracking-widest">

View file

@ -4,9 +4,11 @@ import { DataPoint } from '../components/DataPoint'
import { Container } from '../components/Container'
import { Led } from '../components/Led'
import { useI18n } from '../i18n'
import { useClientOS } from '../hooks/useClientOS'
export function Hero() {
const { t, locale } = useI18n()
const clientOS = useClientOS()
// Interactive Live Simulator State
const [simState, setSimState] = useState<'idle' | 'recording' | 'processing' | 'done'>('idle')
@ -125,15 +127,22 @@ export function Hero() {
</div>
</div>
{/* CTA Action Buttons */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 mb-3.5">
{/* CTA Action Buttons with OS Negotiation */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 mb-3">
<a
href="#download"
href={clientOS.downloadUrl}
download={clientOS.downloadFilename || undefined}
className="glow-btn inline-flex items-center justify-center gap-2.5 px-8 py-4 font-mono text-sm font-semibold uppercase tracking-wider text-white bg-brand-amber rounded-panel shadow-glow-sm hover:shadow-glow-md"
>
<span className="relative z-10 flex items-center gap-2">
<DownloadIcon />
{t.hero.downloadBtn}
<span className="relative z-10 flex items-center gap-2.5">
{clientOS.os === 'android' ? (
<AndroidIcon />
) : clientOS.os === 'ios' || clientOS.os === 'macos' ? (
<AppleIcon />
) : (
<WindowsIcon />
)}
{locale === 'ko' ? clientOS.buttonLabelKo : clientOS.buttonLabelEn}
</span>
</a>
<a
@ -145,6 +154,20 @@ export function Hero() {
</a>
</div>
{/* Platform Subtext & Other OS Navigator */}
<div className="flex flex-wrap items-center gap-y-2 gap-x-4 mb-3 font-mono text-xs">
<span className="text-brand-amber font-medium flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-brand-amber animate-pulse" />
{locale === 'ko' ? clientOS.subtextKo : clientOS.subtextEn}
</span>
<a
href="#download"
className="text-neutral-400 hover:text-neutral-200 underline decoration-neutral-600 underline-offset-4 transition-colors"
>
{locale === 'ko' ? '다른 OS 다운로드 (Win / Mac / Android / iOS) →' : 'Other Platforms (Win / Mac / Android / iOS) →'}
</a>
</div>
{/* Friction-Reduction Trust Microcopy */}
<div className="flex items-center gap-2 font-mono text-nano text-neutral-500">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
@ -274,12 +297,27 @@ export function Hero() {
)
}
function DownloadIcon() {
function AndroidIcon() {
return (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
<svg className="w-4 h-4 text-emerald-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.523 15.3414c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.551 0 .9993.4482.9993.9993.0001.5511-.4483.9997-.9993.9997m-11.046 0c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993 0 .5511-.4482.9997-.9993.9997m11.4045-6.02l1.996-3.4572a.416.416 0 0 0-.1521-.5676.416.416 0 0 0-.5676.1521l-2.0223 3.503C15.5902 8.411 13.856 8.125 12 8.125s-3.5902.286-5.1315.8267L4.8462 5.4487a.4161.4161 0 0 0-.5677-.1521.4157.4157 0 0 0-.1521.5676l1.996 3.4572C2.6889 11.1867.3432 14.6589 0 18.775h24c-.3432-4.1161-2.6889-7.5883-6.1185-9.4536" />
</svg>
)
}
function AppleIcon() {
return (
<svg className="w-4 h-4 text-neutral-200" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M15.97 6.37c.61-.75 1.04-1.8 1.01-2.87-.96.04-2.13.64-2.79 1.43-.58.68-.99 1.74-.94 2.82 1.08.08 2.11-.63 2.72-1.38z" />
</svg>
)
}
function WindowsIcon() {
return (
<svg className="w-4 h-4 text-sky-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.551H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-13.051-1.851" />
</svg>
)
}
@ -302,3 +340,4 @@ function MicIcon() {
</svg>
)
}