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:
parent
ad6bb70c20
commit
e689683b72
44 changed files with 2198 additions and 3782 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue