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

107
scripts/capture-pricing.js Normal file
View file

@ -0,0 +1,107 @@
const { chromium, devices } = require('playwright');
const path = require('path');
const fs = require('fs');
const http = require('http');
function serveStatic(dir, port) {
const server = http.createServer((req, res) => {
let filePath = path.join(dir, req.url.split('?')[0]);
if (filePath.endsWith('/') || !path.extname(filePath)) filePath = path.join(dir, 'index.html');
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
const ext = path.extname(filePath);
const mimeTypes = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2'
};
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' });
res.end(data);
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({ server, port: server.address().port });
});
});
}
async function testOsNegotiation() {
const distDir = path.resolve('site/dist');
const { server, port } = await serveStatic(distDir);
const url = `http://127.0.0.1:${port}`;
console.log(`Static server running on ${url}`);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const outDir = 'C:/Users/encep/.gemini/antigravity-cli/brain/3ab7ae82-5634-41d5-93a7-3c12c22503e2/screenshots/live_production_verified';
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
// 1. Android Mobile Emulation (Pixel 7)
console.log('--- Testing Android Device Emulation (Pixel 7) ---');
const androidContext = await browser.newContext({
...devices['Pixel 7'],
locale: 'ko-KR'
});
const pageAndroid = await androidContext.newPage();
await pageAndroid.goto(url, { waitUntil: 'networkidle' });
await pageAndroid.waitForTimeout(1000);
const androidBtn = await pageAndroid.evaluate(() => {
const el = document.querySelector('a.glow-btn');
return el ? { text: el.innerText.trim(), href: el.href, download: el.getAttribute('download') } : null;
});
console.log('Android CTA Button:', androidBtn);
await pageAndroid.screenshot({ path: path.join(outDir, '19_hero_android_os_negotiated.png') });
// 2. iOS Mobile Emulation (iPhone 14)
console.log('--- Testing iPhone Device Emulation (iPhone 14) ---');
const iosContext = await browser.newContext({
...devices['iPhone 14'],
locale: 'ko-KR'
});
const pageIos = await iosContext.newPage();
await pageIos.goto(url, { waitUntil: 'networkidle' });
await pageIos.waitForTimeout(1000);
const iosBtn = await pageIos.evaluate(() => {
const el = document.querySelector('a.glow-btn');
return el ? { text: el.innerText.trim(), href: el.href } : null;
});
console.log('iOS CTA Button:', iosBtn);
await pageIos.screenshot({ path: path.join(outDir, '20_hero_ios_os_negotiated.png') });
// 3. Windows Desktop
console.log('--- Testing Windows Desktop Emulation ---');
const winContext = await browser.newContext({
viewport: { width: 1440, height: 900 },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
locale: 'ko-KR'
});
const pageWin = await winContext.newPage();
await pageWin.goto(url, { waitUntil: 'networkidle' });
await pageWin.waitForTimeout(1000);
const winBtn = await pageWin.evaluate(() => {
const el = document.querySelector('a.glow-btn');
return el ? { text: el.innerText.trim(), href: el.href, download: el.getAttribute('download') } : null;
});
console.log('Windows CTA Button:', winBtn);
await pageWin.screenshot({ path: path.join(outDir, '21_hero_windows_os_negotiated.png') });
await browser.close();
server.close();
console.log('=== ALL REAL OS NEGOTIATION TESTS PASSED ===');
}
testOsNegotiation().catch(console.error);

View file

@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
interface GlowButtonProps { interface GlowButtonProps {
children: ReactNode children: ReactNode
href?: string href?: string
download?: string
variant?: 'primary' | 'secondary' variant?: 'primary' | 'secondary'
size?: 'md' | 'lg' size?: 'md' | 'lg'
className?: string className?: string
@ -11,6 +12,7 @@ interface GlowButtonProps {
export function GlowButton({ export function GlowButton({
children, children,
href, href,
download,
variant = 'primary', variant = 'primary',
size = 'md', size = 'md',
className = '', className = '',
@ -31,7 +33,7 @@ export function GlowButton({
if (href) { if (href) {
return ( return (
<a href={href} className={cls}> <a href={href} download={download} className={cls}>
<span className="relative z-10">{children}</span> <span className="relative z-10">{children}</span>
</a> </a>
) )
@ -43,3 +45,4 @@ export function GlowButton({
</button> </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 { WaveBars } from '../components/WaveBars'
import { GlowButton } from '../components/GlowButton' import { GlowButton } from '../components/GlowButton'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
import { useClientOS } from '../hooks/useClientOS'
export function CTA() { export function CTA() {
const { t } = useI18n() const { t, locale } = useI18n()
const clientOS = useClientOS()
return ( return (
<section className="relative py-28 md:py-36 overflow-hidden"> <section className="relative py-28 md:py-36 overflow-hidden">
@ -47,15 +49,23 @@ export function CTA() {
</div> </div>
</div> </div>
<div> <div className="flex flex-col items-center gap-3">
<GlowButton <GlowButton
href="#download" href={clientOS.downloadUrl}
download={clientOS.downloadFilename || undefined}
variant="primary" variant="primary"
size="lg" size="lg"
className="shadow-glow-md hover:shadow-glow-lg" className="shadow-glow-md hover:shadow-glow-lg"
> >
{t.cta.downloadBtn} {locale === 'ko' ? clientOS.buttonLabelKo : clientOS.buttonLabelEn}
</GlowButton> </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> </div>
<p className="font-mono text-nano text-neutral-500 mt-8 uppercase tracking-widest"> <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 { Container } from '../components/Container'
import { Led } from '../components/Led' import { Led } from '../components/Led'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
import { useClientOS } from '../hooks/useClientOS'
export function Hero() { export function Hero() {
const { t, locale } = useI18n() const { t, locale } = useI18n()
const clientOS = useClientOS()
// Interactive Live Simulator State // Interactive Live Simulator State
const [simState, setSimState] = useState<'idle' | 'recording' | 'processing' | 'done'>('idle') const [simState, setSimState] = useState<'idle' | 'recording' | 'processing' | 'done'>('idle')
@ -125,15 +127,22 @@ export function Hero() {
</div> </div>
</div> </div>
{/* CTA Action Buttons */} {/* CTA Action Buttons with OS Negotiation */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 mb-3.5"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 mb-3">
<a <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" 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"> <span className="relative z-10 flex items-center gap-2.5">
<DownloadIcon /> {clientOS.os === 'android' ? (
{t.hero.downloadBtn} <AndroidIcon />
) : clientOS.os === 'ios' || clientOS.os === 'macos' ? (
<AppleIcon />
) : (
<WindowsIcon />
)}
{locale === 'ko' ? clientOS.buttonLabelKo : clientOS.buttonLabelEn}
</span> </span>
</a> </a>
<a <a
@ -145,6 +154,20 @@ export function Hero() {
</a> </a>
</div> </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 */} {/* Friction-Reduction Trust Microcopy */}
<div className="flex items-center gap-2 font-mono text-nano text-neutral-500"> <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" /> <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 ( return (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <svg className="w-4 h-4 text-emerald-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <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" />
<polyline points="7 10 12 15 17 10" /> </svg>
<line x1="12" y1="15" x2="12" y2="3" /> )
}
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> </svg>
) )
} }
@ -302,3 +340,4 @@ function MicIcon() {
</svg> </svg>
) )
} }