Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ jobs:
run: |
cd $PROJECT_DIR/apps/web
pnpm install
VITE_API_URL=/api VITE_STORAGE_URL=https://textstack.app VITE_CANONICAL_URL=https://textstack.app pnpm build
VITE_API_URL=/api VITE_STORAGE_URL=https://textstack.app VITE_CANONICAL_URL=https://textstack.app \
VITE_FEATURE_MYBOOKSV3_HEADER_REFRAME=false \
pnpm build

- name: Deploy containers
run: |
Expand Down
26 changes: 22 additions & 4 deletions apps/mobile/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { useTheme } from '../../src/context/ThemeContext'
import { useLanguage } from '../../src/context/LanguageContext'
import { useAuth } from '../../src/context/AuthContext'
import { typography } from '../../src/theme/typography'
import { FEATURES } from '../../src/lib/features'
import { UploadTabButton } from '../../src/components/UploadTabButton'

function AnimatedTabIcon({ name, size, color, focused }: {
name: keyof typeof Ionicons.glyphMap; size: number; color: string; focused: boolean
Expand All @@ -28,6 +31,7 @@ function AnimatedTabIcon({ name, size, color, focused }: {

const TAB_ICONS: Record<string, { active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }> = {
Read: { active: 'book', inactive: 'book-outline' },
Home: { active: 'home', inactive: 'home-outline' },
Discover: { active: 'compass', inactive: 'compass-outline' },
Library: { active: 'library', inactive: 'library-outline' },
Vocabulary: { active: 'school', inactive: 'school-outline' },
Expand All @@ -37,7 +41,9 @@ const TAB_ICONS: Record<string, { active: keyof typeof Ionicons.glyphMap; inacti
export default function TabLayout() {
const { colors } = useTheme()
const { t } = useLanguage()
const { isAuthenticated } = useAuth()
const insets = useSafeAreaInsets()
const v3 = FEATURES.myBooksV3HeaderReframe

// On iOS the old hardcoded 88 already approximated the home-indicator
// safe area. On Android the 60 didn't account for 3-button nav bars or
Expand Down Expand Up @@ -69,11 +75,12 @@ export default function TabLayout() {
<Tabs.Screen
name="index"
options={{
title: t('nav.read'),
title: v3 ? t('nav.home') : t('nav.read'),
headerShown: false,
tabBarIcon: ({ focused, color }) => (
<AnimatedTabIcon name={focused ? TAB_ICONS.Read.active : TAB_ICONS.Read.inactive} size={22} color={color} focused={focused} />
),
tabBarIcon: ({ focused, color }) => {
const icon = v3 ? TAB_ICONS.Home : TAB_ICONS.Read
return <AnimatedTabIcon name={focused ? icon.active : icon.inactive} size={22} color={color} focused={focused} />
},
}}
/>
<Tabs.Screen
Expand All @@ -85,6 +92,15 @@ export default function TabLayout() {
),
}}
/>
<Tabs.Screen
name="upload"
options={{
title: '',
// v3 only: render raised + button. Otherwise hide tab entirely.
href: v3 && isAuthenticated ? '/my-books/upload' : null,
tabBarButton: v3 && isAuthenticated ? () => <UploadTabButton /> : undefined,
}}
/>
<Tabs.Screen
name="library"
options={{
Expand All @@ -107,6 +123,8 @@ export default function TabLayout() {
name="profile"
options={{
title: t('nav.profile'),
// v3 hides Profile from bottom tabs (accessible via header avatar).
href: v3 ? null : undefined,
tabBarIcon: ({ focused, color }) => (
<AnimatedTabIcon name={focused ? TAB_ICONS.Profile.active : TAB_ICONS.Profile.inactive} size={22} color={color} focused={focused} />
),
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/app/(tabs)/upload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Placeholder route — actual screen lives at /my-books/upload.
// The center "+" tab uses a custom tabBarButton that navigates there
// directly; this component never renders.
export default function UploadTabPlaceholder() {
return null
}
57 changes: 57 additions & 0 deletions apps/mobile/src/components/UploadTabButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { TouchableOpacity, View, StyleSheet, Platform } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { useRouter } from 'expo-router'
import { useTheme } from '../context/ThemeContext'
import { useAuth } from '../context/AuthContext'

export function UploadTabButton() {
const { colors } = useTheme()
const { isAuthenticated } = useAuth()
const router = useRouter()

const onPress = () => {
if (!isAuthenticated) {
router.push('/auth/login')
return
}
router.push('/my-books/upload')
}

return (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Upload"
onPress={onPress}
activeOpacity={0.85}
style={styles.wrapper}
>
<View style={[styles.button, { backgroundColor: colors.primary, shadowColor: colors.text }]}>
<Ionicons name="add" size={28} color="#fff" />
</View>
</TouchableOpacity>
)
}

const styles = StyleSheet.create({
wrapper: {
top: -18,
justifyContent: 'center',
alignItems: 'center',
flex: 1,
},
button: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
...Platform.select({
ios: {
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.25,
shadowRadius: 6,
},
android: { elevation: 6 },
}),
},
})
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const FEATURES = {
readerOverlayV2: readBool(process.env.EXPO_PUBLIC_READER_OVERLAY_V2, true),
// My Books v2 — Continue Reading shelf at top of Library (Phase 2 / slice 05).
myBooksV2ContinueReading: readBool(process.env.EXPO_PUBLIC_MYBOOKS_V2_CONTINUE_READING, true),
// My Books v3 — header/tab reframe (Home/Discover/+/Library/Vocab on mobile).
// Default OFF in prod; enable AFTER slice 04 ships smart shelves on /home.
myBooksV3HeaderReframe: readBool(process.env.EXPO_PUBLIC_MYBOOKSV3_HEADER_REFRAME, false),
} as const

export type FeatureKey = keyof typeof FEATURES
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/DiscoverMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { LocalizedLink } from './LocalizedLink'
import { useApi } from '../hooks/useApi'
import { useTranslation } from '../hooks/useTranslation'
import { getStorageUrl } from '../api/client'
import { features } from '../lib/features'

interface Genre {
id: string
Expand Down Expand Up @@ -103,7 +104,7 @@ export function DiscoverMenu() {
aria-expanded={open}
aria-haspopup="true"
>
{t('nav.books')}
{t(features.myBooksV3.headerReframe ? 'nav.discover' : 'nav.books')}
<span className="discover-menu__chevron" aria-hidden="true">
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
Expand Down
74 changes: 66 additions & 8 deletions apps/web/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { useQuickStats } from '../hooks/useQuickStats'
import { StreakBadge } from './StreakBadge'
import { VocabBadgePopup } from './VocabBadgePopup'
import { UploadButton } from './library/UploadButton'
import { features } from '../lib/features'
import { emit } from '../lib/telemetry/myBooksV3'

export function Header() {
const [badgePopup, setBadgePopup] = useState(false)
Expand All @@ -21,20 +23,76 @@ export function Header() {
const { t } = useTranslation()
const quickStats = useQuickStats()

const v3 = features.myBooksV3.headerReframe
// /home doesn't exist until slice 03 — fall back to /library.
const homeTarget = '/library'

return (
<header className={`site-header ${isScrolled ? 'site-header--scrolled' : ''}`}>
<div className="site-header__left">
<LocalizedLink to="/" className="site-header__brand" title={t('nav.brandTitle')}>
<LocalizedLink
to={v3 && isAuthenticated ? homeTarget : '/'}
className="site-header__brand"
title={t('nav.brandTitle')}
onClick={() => v3 && emit('header.click', { item: 'logo', auth: isAuthenticated })}
>
<span className="site-header__wordmark">TextStack</span>
</LocalizedLink>
<nav className="site-header__nav-links">
<DiscoverMenu />
<LocalizedLink to="/vocabulary" className="site-header__nav-link" title={t('nav.vocabulary')}>
{t('nav.vocabulary')}
</LocalizedLink>
<LocalizedLink to="/about" className="site-header__nav-link site-header__nav-link--secondary" title={t('nav.aboutTextStack')}>
{t('nav.about')}
</LocalizedLink>
{v3 ? (
<>
{isAuthenticated && (
<LocalizedLink
to={homeTarget}
className="site-header__nav-link"
title={t('nav.home')}
onClick={() => emit('header.click', { item: 'home' })}
>
{t('nav.home')}
</LocalizedLink>
)}
{isAuthenticated && (
<LocalizedLink
to="/library"
className="site-header__nav-link"
title={t('nav.library')}
onClick={() => emit('header.click', { item: 'library' })}
>
{t('nav.library')}
</LocalizedLink>
)}
<DiscoverMenu />
{isAuthenticated && (
<LocalizedLink
to="/vocabulary"
className="site-header__nav-link"
title={t('nav.vocabulary')}
onClick={() => emit('header.click', { item: 'vocabulary' })}
>
{t('nav.vocabulary')}
</LocalizedLink>
)}
{!isAuthenticated && (
<LocalizedLink
to="/about"
className="site-header__nav-link site-header__nav-link--secondary"
title={t('nav.aboutTextStack')}
>
{t('nav.about')}
</LocalizedLink>
)}
</>
) : (
<>
<DiscoverMenu />
<LocalizedLink to="/vocabulary" className="site-header__nav-link" title={t('nav.vocabulary')}>
{t('nav.vocabulary')}
</LocalizedLink>
<LocalizedLink to="/about" className="site-header__nav-link site-header__nav-link--secondary" title={t('nav.aboutTextStack')}>
{t('nav.about')}
</LocalizedLink>
</>
)}
</nav>
</div>
<div className="site-header__right">
Expand Down
113 changes: 113 additions & 0 deletions apps/web/src/components/__tests__/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { Header } from '../Header'

const authState = { isAuthenticated: false, isLoading: false }

vi.mock('../../context/AuthContext', () => ({
useAuth: () => authState,
}))
vi.mock('../../context/LanguageContext', () => ({
useLanguage: () => ({
language: 'en',
getLocalizedPath: (p: string) => `/en${p === '/' ? '' : p}`,
switchLanguage: () => {},
}),
}))
vi.mock('../../hooks/useScrolled', () => ({ useScrolled: () => false }))
vi.mock('../../hooks/useDarkMode', () => ({ useDarkMode: () => ({ isDark: false, toggleTheme: () => {} }) }))
vi.mock('../../hooks/useTranslation', () => ({
useTranslation: () => ({
t: (key: string) => {
const map: Record<string, string> = {
'nav.home': 'Home',
'nav.library': 'Library',
'nav.discover': 'Discover',
'nav.vocabulary': 'Vocabulary',
'nav.about': 'About',
'nav.aboutTextStack': 'About TextStack',
'nav.brandTitle': 'TextStack',
}
return map[key] ?? key
},
}),
}))
vi.mock('../../hooks/useQuickStats', () => ({ useQuickStats: () => null }))
vi.mock('../DiscoverMenu', () => ({ DiscoverMenu: () => <div data-testid="discover-menu">Discover</div> }))
vi.mock('../auth/LoginButton', () => ({ LoginButton: () => <button>Sign in</button> }))
vi.mock('../auth/UserMenu', () => ({ UserMenu: () => <div data-testid="user-menu" /> }))
vi.mock('../library/UploadButton', () => ({ UploadButton: () => <button>Upload</button> }))
vi.mock('../StreakBadge', () => ({ StreakBadge: () => null }))
vi.mock('../VocabBadgePopup', () => ({ VocabBadgePopup: () => null }))

const flagState = { v3: true }
vi.mock('../../lib/features', () => ({
features: {
get myBooksV3() { return { headerReframe: flagState.v3 } },
},
}))

function renderHeader() {
return render(
<MemoryRouter>
<Header />
</MemoryRouter>
)
}

describe('Header (myBooksV3 reframe)', () => {
beforeEach(() => {
flagState.v3 = true
authState.isAuthenticated = false
authState.isLoading = false
})

it('flag ON + authenticated: shows Home / Library / Discover / Vocabulary, hides About', () => {
flagState.v3 = true
authState.isAuthenticated = true
renderHeader()
expect(screen.getByTitle('Home')).toBeInTheDocument()
expect(screen.getByTitle('Library')).toBeInTheDocument()
expect(screen.getByTestId('discover-menu')).toBeInTheDocument()
expect(screen.getByTitle('Vocabulary')).toBeInTheDocument()
expect(screen.queryByTitle('About TextStack')).not.toBeInTheDocument()
})

it('flag ON + unauthenticated: hides Home/Library/Vocabulary, keeps Discover + About', () => {
flagState.v3 = true
authState.isAuthenticated = false
renderHeader()
expect(screen.queryByTitle('Home')).not.toBeInTheDocument()
expect(screen.queryByTitle('Library')).not.toBeInTheDocument()
expect(screen.getByTestId('discover-menu')).toBeInTheDocument()
expect(screen.getByTitle('About TextStack')).toBeInTheDocument()
})

it('flag OFF: legacy structure (Discover + Vocabulary + About, no Home / Library)', () => {
flagState.v3 = false
authState.isAuthenticated = true
renderHeader()
expect(screen.queryByTitle('Home')).not.toBeInTheDocument()
expect(screen.queryByTitle('Library')).not.toBeInTheDocument()
expect(screen.getByTestId('discover-menu')).toBeInTheDocument()
expect(screen.getByTitle('Vocabulary')).toBeInTheDocument()
expect(screen.getByTitle('About TextStack')).toBeInTheDocument()
})

it('flag ON + authenticated: logo links to /en/library (home fallback until slice 03)', () => {
flagState.v3 = true
authState.isAuthenticated = true
renderHeader()
const brand = screen.getByTitle('TextStack')
expect(brand).toHaveAttribute('href', '/en/library')
})

it('flag ON + unauthenticated: logo links to /en (marketing root)', () => {
flagState.v3 = true
authState.isAuthenticated = false
renderHeader()
const brand = screen.getByTitle('TextStack')
expect(brand).toHaveAttribute('href', '/en')
})
})
Loading
Loading