Skip to content

Commit 3e53e3e

Browse files
mrviduusclaude
andcommitted
feat(hero): weave native language into subtitle, clean header
- Subtitle: "Tap any word to translate to **Русский** — and stay in flow" with clickable native lang name opening a picker popover - Header: hide LanguageSwitcher on home (language is in hero) - Header: remove search icon on all pages (search stays in hero only) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7434973 commit 3e53e3e

5 files changed

Lines changed: 211 additions & 16 deletions

File tree

apps/web/src/components/Header.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useRef } from 'react'
2+
import { useLocation } from 'react-router-dom'
23
import { LocalizedLink } from './LocalizedLink'
3-
import { MobileSearchOverlay } from './Search'
44
import { LanguageSwitcher } from './LanguageSwitcher'
55
import { LoginButton } from './auth/LoginButton'
66
import { UserMenu } from './auth/UserMenu'
@@ -13,14 +13,15 @@ import { StreakBadge } from './StreakBadge'
1313
import { VocabBadgePopup } from './VocabBadgePopup'
1414

1515
export function Header() {
16-
const [searchOpen, setSearchOpen] = useState(false)
1716
const [badgePopup, setBadgePopup] = useState(false)
1817
const badgeWrapperRef = useRef<HTMLDivElement>(null)
1918
const { isAuthenticated, isLoading } = useAuth()
2019
const isScrolled = useScrolled(50)
2120
const { isDark, toggleTheme } = useDarkMode()
2221
const { t } = useTranslation()
2322
const quickStats = useQuickStats()
23+
const location = useLocation()
24+
const isHomePage = /^\/(en|uk)?\/?$/.test(location.pathname)
2425

2526
return (
2627
<header className={`site-header ${isScrolled ? 'site-header--scrolled' : ''}`}>
@@ -44,21 +45,14 @@ export function Header() {
4445
</nav>
4546
</div>
4647
<div className="site-header__right">
47-
<LanguageSwitcher />
48+
{!isHomePage && <LanguageSwitcher />}
4849
<button
4950
className="site-header__icon-btn"
5051
onClick={toggleTheme}
5152
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
5253
>
5354
<span className="material-icons-outlined">{isDark ? 'light_mode' : 'dark_mode'}</span>
5455
</button>
55-
<button
56-
className="site-header__icon-btn"
57-
onClick={() => setSearchOpen(true)}
58-
aria-label="Search"
59-
>
60-
<span className="material-icons-outlined">search</span>
61-
</button>
6256
{isAuthenticated && quickStats && (quickStats.vocabDueNow > 0 || quickStats.vocabReviewedToday > 0) && (
6357
<div className="streak-badge-wrapper" ref={badgeWrapperRef}>
6458
<button
@@ -85,7 +79,6 @@ export function Header() {
8579
)}
8680
{!isLoading && (isAuthenticated ? <UserMenu /> : <LoginButton />)}
8781
</div>
88-
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
8982
</header>
9083
)
9184
}

apps/web/src/components/home/HeroSection.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
22
import { useNavigate } from 'react-router-dom'
33
import { useTranslation } from '../../hooks/useTranslation'
44
import { useLanguage } from '../../context/LanguageContext'
5+
import { useNativeLanguage } from '../../context/NativeLanguageContext'
56
import { useAuth } from '../../context/AuthContext'
67
import { useGuestLimits } from '../../context/GuestLimitsContext'
78
import { DEMO_BOOK } from '../../config/demoBook'
@@ -10,6 +11,8 @@ import { LocalizedLink } from '../LocalizedLink'
1011
import { uploadUserBook } from '../../api/userBooks'
1112
import { useContinueReading } from '../../hooks/useContinueReading'
1213
import { ContinueReadingCard } from './ContinueReadingCard'
14+
import { FazierBadge } from './FazierBadge'
15+
import { POPULAR_LANGUAGES, getLanguage, getFlagUrl } from '../../data/languages'
1316

1417
export function HeroSection() {
1518
const { t } = useTranslation()
@@ -23,7 +26,11 @@ export function HeroSection() {
2326
const [searchOverlayOpen, setSearchOverlayOpen] = useState(false)
2427
const [uploading, setUploading] = useState(false)
2528
const [uploadError, setUploadError] = useState<string | null>(null)
29+
const [langPickerOpen, setLangPickerOpen] = useState(false)
2630
const fileInputRef = useRef<HTMLInputElement>(null)
31+
const langTriggerRef = useRef<HTMLDivElement>(null)
32+
const { nativeLanguage, setNativeLanguage, hasConfirmedLanguage } = useNativeLanguage()
33+
const nativeLang = getLanguage(nativeLanguage)
2734

2835
useEffect(() => {
2936
const checkMobile = () => setIsMobile(window.innerWidth < 640)
@@ -32,6 +39,18 @@ export function HeroSection() {
3239
return () => window.removeEventListener('resize', checkMobile)
3340
}, [])
3441

42+
// Close lang picker on outside click
43+
useEffect(() => {
44+
if (!langPickerOpen) return
45+
const handler = (e: MouseEvent) => {
46+
if (langTriggerRef.current && !langTriggerRef.current.contains(e.target as Node)) {
47+
setLangPickerOpen(false)
48+
}
49+
}
50+
document.addEventListener('mousedown', handler)
51+
return () => document.removeEventListener('mousedown', handler)
52+
}, [langPickerOpen])
53+
3554
const handleSearch = (e: React.FormEvent) => {
3655
e.preventDefault()
3756
if (query.trim()) {
@@ -78,10 +97,52 @@ export function HeroSection() {
7897
{continueReadingBook && <ContinueReadingCard book={continueReadingBook} />}
7998
<h1 className="home-hero__title">{t('home.hero.title')}</h1>
8099
<p className="home-hero__subtitle">
81-
{showGuestCta ? t('home.hero.subtitleDemo') : t('home.hero.subtitle')}
100+
{t('home.hero.subtitleBefore')}{' '}
101+
<span className="home-hero__lang-trigger-wrap" ref={langTriggerRef}>
102+
<button
103+
type="button"
104+
className={`home-hero__lang-trigger${!hasConfirmedLanguage ? ' home-hero__lang-trigger--pulse' : ''}`}
105+
onClick={() => setLangPickerOpen(o => !o)}
106+
>
107+
{nativeLang && (
108+
<img
109+
className="home-hero__lang-flag"
110+
src={getFlagUrl(nativeLang.code)}
111+
alt=""
112+
width="20"
113+
height="15"
114+
/>
115+
)}
116+
{nativeLang?.nativeName ?? nativeLanguage}
117+
</button>
118+
{langPickerOpen && (
119+
<div className="home-hero__lang-popover">
120+
<ul className="home-hero__lang-list" role="listbox">
121+
{POPULAR_LANGUAGES.filter(l => l.code !== language).map(l => (
122+
<li
123+
key={l.code}
124+
role="option"
125+
aria-selected={l.code === nativeLanguage}
126+
className={`home-hero__lang-option${l.code === nativeLanguage ? ' selected' : ''}`}
127+
onClick={() => { setNativeLanguage(l.code); setLangPickerOpen(false) }}
128+
>
129+
<img src={getFlagUrl(l.code)} alt="" width="20" height="15" />
130+
<span>{l.nativeName}</span>
131+
{l.englishName !== l.nativeName && (
132+
<span className="home-hero__lang-english">{l.englishName}</span>
133+
)}
134+
</li>
135+
))}
136+
</ul>
137+
</div>
138+
)}
139+
</span>{' '}
140+
{t('home.hero.subtitleAfter')}
82141
</p>
83142
<p className="home-hero__brand-line">{t('home.hero.brandLine')}</p>
84143

144+
<FazierBadge />
145+
85146
{showGuestCta && (
86147
<div className="home-hero__cta-group">
87148
<LocalizedLink

apps/web/src/locales/en.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"home": {
33
"hero": {
44
"title": "Read books in English without stopping",
5-
"subtitle": "Tap any word to translate instantly — and stay in flow",
6-
"subtitleDemo": "Tap any word to translate instantly — and stay in flow",
5+
"subtitleBefore": "Tap any word to translate to",
6+
"subtitleAfter": "— and stay in flow",
77
"brandLine": "TextStack Reader — learn English by reading real books",
88
"seoTitle": "TextStack Reader — Read books in English with instant translation",
99
"seoDescription": "Read books in English without stopping. Tap any word to translate instantly and stay in flow. Learn English by reading real books with TextStack.",

apps/web/src/locales/uk.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"home": {
33
"hero": {
44
"title": "Читай книги англійською без зупинок",
5-
"subtitle": "Торкніться будь-якого слова, щоб миттєво перекласти — і залишайтесь у потоці",
6-
"subtitleDemo": "Торкніться будь-якого слова, щоб миттєво перекласти — і залишайтесь у потоці",
5+
"subtitleBefore": "Натисни на слово для перекладу на",
6+
"subtitleAfter": "— читай без зупинок",
77
"brandLine": "TextStack Reader — вивчай англійську, читаючи справжні книги",
88
"seoTitle": "TextStack Reader — Читай книги англійською з миттєвим перекладом",
99
"seoDescription": "Читай книги англійською без зупинок. Торкніться будь-якого слова, щоб миттєво перекласти, і залишайтесь у потоці. Вивчай англійську, читаючи справжні книги з TextStack.",

apps/web/src/styles/home.css

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,129 @@
4040
line-height: 1.5;
4141
}
4242

43+
/* Inline native language trigger inside subtitle */
44+
.home-hero__lang-trigger-wrap {
45+
position: relative;
46+
display: inline;
47+
}
48+
49+
.home-hero__lang-trigger {
50+
display: inline-flex;
51+
align-items: center;
52+
gap: 5px;
53+
background: none;
54+
border: none;
55+
font: inherit;
56+
font-style: normal;
57+
font-weight: 600;
58+
color: var(--color-text);
59+
cursor: pointer;
60+
padding: 2px 4px;
61+
border-radius: 4px;
62+
text-decoration: underline;
63+
text-decoration-style: dotted;
64+
text-underline-offset: 3px;
65+
text-decoration-color: var(--color-text-secondary);
66+
transition: background 0.15s, color 0.15s;
67+
vertical-align: baseline;
68+
}
69+
70+
.home-hero__lang-trigger:hover {
71+
background: var(--color-warm-bg, rgba(255, 152, 0, 0.08));
72+
}
73+
74+
.home-hero__lang-trigger--pulse {
75+
animation: hero-lang-pulse 2.2s ease-in-out infinite;
76+
}
77+
78+
@keyframes hero-lang-pulse {
79+
0%, 100% { background: transparent; }
80+
50% { background: var(--color-warm-bg, rgba(255, 152, 0, 0.12)); }
81+
}
82+
83+
@media (prefers-reduced-motion: reduce) {
84+
.home-hero__lang-trigger--pulse { animation: none; background: var(--color-warm-bg, rgba(255, 152, 0, 0.08)); }
85+
}
86+
87+
.home-hero__lang-flag {
88+
width: 20px;
89+
height: 15px;
90+
border-radius: 2px;
91+
vertical-align: middle;
92+
}
93+
94+
.home-hero__lang-popover {
95+
position: absolute;
96+
bottom: calc(100% + 8px);
97+
left: 50%;
98+
transform: translateX(-50%);
99+
z-index: 200;
100+
}
101+
102+
@media (max-width: 480px) {
103+
.home-hero__lang-popover {
104+
left: auto;
105+
right: -40px;
106+
transform: none;
107+
}
108+
}
109+
110+
.home-hero__lang-list {
111+
list-style: none;
112+
margin: 0;
113+
padding: 6px;
114+
background: var(--color-surface, #fff);
115+
border: 1px solid var(--color-border, #e0e0e0);
116+
border-radius: 10px;
117+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
118+
min-width: 220px;
119+
max-height: 400px;
120+
overflow-y: auto;
121+
}
122+
123+
.home-hero__lang-option {
124+
display: flex;
125+
align-items: center;
126+
gap: 10px;
127+
padding: 8px 12px;
128+
border-radius: 6px;
129+
cursor: pointer;
130+
font-style: normal;
131+
font-size: 14px;
132+
font-weight: 400;
133+
color: var(--color-text);
134+
transition: background 0.1s;
135+
}
136+
137+
.home-hero__lang-option:hover {
138+
background: var(--color-warm-bg, rgba(255, 152, 0, 0.06));
139+
}
140+
141+
.home-hero__lang-option.selected {
142+
font-weight: 600;
143+
}
144+
145+
.home-hero__lang-option.selected::after {
146+
content: '✓';
147+
margin-left: auto;
148+
color: var(--color-accent, #f57c00);
149+
}
150+
151+
.home-hero__lang-option img {
152+
border-radius: 2px;
153+
flex-shrink: 0;
154+
}
155+
156+
.home-hero__lang-english {
157+
color: var(--color-text-secondary);
158+
font-size: 12px;
159+
margin-left: auto;
160+
}
161+
162+
.home-hero__lang-option.selected .home-hero__lang-english {
163+
margin-left: 0;
164+
}
165+
43166
.home-hero__brand-line {
44167
font-family: 'Crimson Pro', serif;
45168
font-size: 14px;
@@ -49,6 +172,24 @@
49172
letter-spacing: 0.01em;
50173
}
51174

175+
/* Fazier Badge */
176+
.fazier-badge {
177+
display: inline-block;
178+
margin-top: 24px;
179+
opacity: 0.85;
180+
transition: opacity 0.2s ease;
181+
}
182+
183+
.fazier-badge:hover {
184+
opacity: 1;
185+
}
186+
187+
.fazier-badge img {
188+
display: block;
189+
height: 44px;
190+
width: auto;
191+
}
192+
52193
/* CTA Buttons */
53194
.home-hero__cta-group {
54195
display: flex;

0 commit comments

Comments
 (0)