feat(site): rewrite the landing page around verified facts and accessible controls

- Replace claims that contradicted the app: the default hotkey is Right Alt
  (hold to talk), local features are free with no daily cap, there is no
  14-day trial, Team/SSO/SCIM/ZDR are not offered, and the repository is not
  public. Remove invented metrics, status badges, the competitor table, the
  ad-mediation changelog, the hash calculator and the duplicate demo.
- Seven sections: hero with one labelled example, features, how it works,
  privacy, pricing (Free / Pro 2,900 / Pro+ 8,900 KRW a month), download, FAQ.
  Footer links the privacy policy, terms and account deletion pages.
- All copy moves into i18n and every one of the 10 locales is translated.
  Pricing and cloud quotas come from site/src/pricing.ts.
- Accessibility: skip link, labelled sections, aria-expanded with Esc and
  focus return for the menu, language list and FAQ, live status for the
  example, reduced-motion support, 44px targets, AA contrast on the primary
  button. Korean keep-all line breaking is scoped to :lang(ko) because
  Tailwind's break-keep blocked wrapping in Japanese and Chinese.
- JS 111 -> 98 KB gzip, CSS 7.8 -> 5.2 KB gzip.
This commit is contained in:
Yun Chan 2026-09-26 15:04:43 +09:00
parent ad6bb70c20
commit e689683b72
44 changed files with 2198 additions and 3782 deletions

View file

@ -14,21 +14,9 @@ import { vi } from './locales/vi'
// ── Types ──────────────────────────────────────────────
export type Locale = 'en' | 'ko' | 'ja' | 'zh' | 'es' | 'fr' | 'de' | 'pt' | 'ru' | 'vi'
export interface FeatureItem {
export interface TitledText {
title: string
description: string
}
export interface PipelineStep {
label: string
title: string
description: string
detail: string
}
export interface PrivacyMetricItem {
label: string
value: string
body: string
}
export interface FaqItem {
@ -36,128 +24,143 @@ export interface FaqItem {
a: string
}
export interface PricingFeatureRow {
feature: string
free: string | boolean
pro: string | boolean
proPlus: string | boolean
export interface PlanCopy {
name: string
desc: string
cta: string
features: string[]
}
/**
* 사이트의 모든 문구. 컴포넌트에 문자열을 직접 쓰지 않는다.
* `{name}` 자리표시자는 `fmt()`로 채운다.
*/
export interface Translations {
meta: {
title: string
description: string
}
a11y: {
skipToContent: string
primaryNav: string
openMenu: string
closeMenu: string
language: string
opensInNewTab: string
}
nav: {
features: string
pipeline: string
how: string
privacy: string
pricing: string
faq: string
download: string
releases?: string
}
hero: {
badge: string
title1: string
title2: string
title3: string
subtitle: string
downloadBtn: string
viewFeatures: string
dataProcessing: string
dataCloudTraffic: string
dataLatency: string
dataPrivacy: string
systemStatus: string
protocol: string
hotkeyKey: string
hotkeyAction: string
secondaryCta: string
trust: string
/** 방문자가 Windows가 아닐 때 다운로드 버튼 아래에 보인다. */
windowsOnly: string
}
demo: {
title: string
note: string
idle: string
listening: string
polishing: string
done: string
play: string
replay: string
rawLabel: string
cleanLabel: string
sampleRaw: string
sampleClean: string
}
features: {
index: string
title: string
subtitle: string
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem]
items: [TitledText, TitledText, TitledText, TitledText, TitledText, TitledText]
}
pipeline: {
index: string
how: {
title: string
subtitle: string
steps: [PipelineStep, PipelineStep, PipelineStep, PipelineStep]
panelTitle: string
panelActive: string
panelLatency: string
sttReady: string
llmConnected: string
transcription: string
aiPolish: string
sampleInput: string
sampleOutput: string
steps: [TitledText, TitledText, TitledText, TitledText]
}
privacy: {
index: string
title: string
subtitle: string
monitorTitle: string
allClear: string
metrics: [PrivacyMetricItem, PrivacyMetricItem, PrivacyMetricItem, PrivacyMetricItem, PrivacyMetricItem, PrivacyMetricItem]
guarantees: [string, string, string, string, string, string]
points: [TitledText, TitledText, TitledText, TitledText]
policyLink: string
}
pricing: {
index: string
title: string
subtitle: string
featureLabel: string
free: string
pro: string
proPlus: string
forever: string
monthly: string
annual: string
perMonth: string
popular: string
savePercent: string
downloadFree: string
getPro: string
getProPlus: string
taxNote: string
billingToggleMonthly: string
billingToggleAnnual: string
rows: [PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow]
unlimited: string
note: string
free: PlanCopy
pro: PlanCopy
proPlus: PlanCopy
}
download: {
title: string
subtitle: string
windowsName: string
windowsMeta: string
cta: string
/** {date}: 게시일 (release.ts의 DESKTOP_RELEASE_DATE, 버전 동기화 대상) */
details: string
releaseNotes: string
checksum: string
soonTitle: string
soon: [TitledText, TitledText]
}
faq: {
index: string
title: string
items: [FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem]
}
cta: {
title1: string
title2: string
subtitle1: string
subtitle2: string
downloadBtn: string
systemReq: string
}
footer: {
description: string
privacy: string
terms: string
deleteAccount: string
releaseNotes: string
copyright: string
builtWith: string
}
}
/** `{key}` 자리표시자를 값으로 바꾼다. */
export function fmt(template: string, values: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) =>
key in values ? String(values[key]) : match,
)
}
// ── Locale metadata ────────────────────────────────────
export interface LocaleMeta {
code: Locale
label: string
nativeName: string
/** Intl 포맷용 BCP 47 태그 */
bcp47: string
}
export const LOCALES: LocaleMeta[] = [
{ code: 'en', label: 'EN', nativeName: 'English' },
{ code: 'ko', label: 'KO', nativeName: '한국어' },
{ code: 'ja', label: 'JA', nativeName: '日本語' },
{ code: 'zh', label: 'ZH', nativeName: '中文' },
{ code: 'es', label: 'ES', nativeName: 'Espanol' },
{ code: 'fr', label: 'FR', nativeName: 'Francais' },
{ code: 'de', label: 'DE', nativeName: 'Deutsch' },
{ code: 'pt', label: 'PT', nativeName: 'Portugues' },
{ code: 'ru', label: 'RU', nativeName: 'Русский' },
{ code: 'vi', label: 'VI', nativeName: 'Tieng Viet' },
{ code: 'ko', label: 'KO', nativeName: '한국어', bcp47: 'ko-KR' },
{ code: 'en', label: 'EN', nativeName: 'English', bcp47: 'en-US' },
{ code: 'ja', label: 'JA', nativeName: '日本語', bcp47: 'ja-JP' },
{ code: 'zh', label: 'ZH', nativeName: '简体中文', bcp47: 'zh-CN' },
{ code: 'es', label: 'ES', nativeName: 'Español', bcp47: 'es-ES' },
{ code: 'fr', label: 'FR', nativeName: 'Français', bcp47: 'fr-FR' },
{ code: 'de', label: 'DE', nativeName: 'Deutsch', bcp47: 'de-DE' },
{ code: 'pt', label: 'PT', nativeName: 'Português', bcp47: 'pt-BR' },
{ code: 'ru', label: 'RU', nativeName: 'Русский', bcp47: 'ru-RU' },
{ code: 'vi', label: 'VI', nativeName: 'Tiếng Việt', bcp47: 'vi-VN' },
]
// ── Translations map ───────────────────────────────────
@ -168,31 +171,31 @@ const translations: Record<Locale, Translations> = {
// ── Storage ────────────────────────────────────────────
const STORAGE_KEY = 'd3ro-locale'
function isLocale(value: string): value is Locale {
return value in translations
}
function detectLocale(): Locale {
// 1. Check localStorage
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored && stored in translations) return stored as Locale
} catch { /* noop */ }
if (stored && isLocale(stored)) return stored
} catch { /* 저장소 차단 환경 */ }
// 2. Check browser language
const browserLang = navigator.language.toLowerCase()
const exact = LOCALES.find((l) => browserLang === l.code || browserLang.startsWith(l.code + '-'))
if (exact) return exact.code
// 3. Fallback
return 'en'
const match = LOCALES.find((l) => browserLang === l.code || browserLang.startsWith(l.code + '-'))
return match ? match.code : 'en'
}
function persistLocale(locale: Locale): void {
try {
localStorage.setItem(STORAGE_KEY, locale)
} catch { /* noop */ }
} catch { /* 저장소 차단 환경 */ }
}
// ── Context ────────────────────────────────────────────
interface I18nContextValue {
locale: Locale
bcp47: string
t: Translations
setLocale: (locale: Locale) => void
}
@ -213,21 +216,20 @@ interface I18nProviderProps {
export function I18nProvider({ children }: I18nProviderProps) {
const [locale, setLocaleState] = useState<Locale>(detectLocale)
const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale)
persistLocale(newLocale)
document.documentElement.lang = newLocale
const setLocale = useCallback((next: Locale) => {
setLocaleState(next)
persistLocale(next)
}, [])
const t = translations[locale]
const bcp47 = LOCALES.find((l) => l.code === locale)?.bcp47 ?? 'en-US'
// 문서 언어와 메타데이터도 선택한 언어를 따른다.
useEffect(() => {
document.documentElement.lang = locale
}, [locale])
document.title = t.meta.title
document.querySelector('meta[name="description"]')?.setAttribute('content', t.meta.description)
}, [locale, t])
const value: I18nContextValue = {
locale,
t: translations[locale],
setLocale,
}
return createElement(I18nContext.Provider, { value }, children)
return createElement(I18nContext.Provider, { value: { locale, bcp47, t, setLocale } }, children)
}