diff --git a/bounty-2/DESIGN.md b/bounty-2/DESIGN.md
new file mode 100644
index 00000000..54fd4a60
--- /dev/null
+++ b/bounty-2/DESIGN.md
@@ -0,0 +1,59 @@
+# Classic Inbox View — Component Architecture
+
+## Overview
+
+The Classic Inbox is a full-screen mail list view. It mimics a traditional email client with a sticky top navigation, scroll-hiding header, category filtering, search, and a paginated email list with read/unread visual states.
+
+## Layout (top to bottom)
+
+```
+┌─────────────────────────────────────┐
+│ TopNav (account + nav buttons) │ ← sticky, always visible
+├─────────────────────────────────────┤
+│ CategoryBar (Primary/Promos/Updates)│ ← hides on scroll down
+│ SearchBar (input + filter icon) │ ← hides on scroll down
+├─────────────────────────────────────┤
+│ EmailList (Animated.FlatList) │ ← scrollable, drives header hide
+│ └─ EmailListItem (swipeable row) │
+│ └─ ... │
+└─────────────────────────────────────┘
+```
+
+## Component Tree
+
+```
+App
+ └─ ClassicInbox (orchestrator + Animated header)
+ ├─ TopNav
+ ├─ Animated.View (header group)
+ │ ├─ CategoryBar
+ │ └─ SearchBar
+ └─ Animated.FlatList
+ └─ EmailListItem (×N)
+```
+
+## Data Flow
+
+- `inboxStore` (MobX) holds emails, categories, selected account, navigation route, search/filter state.
+- `ClassicInbox` reads from store and passes props down.
+- Scroll events on FlatList drive `Animated.Value` used to translate the header group up/down.
+- `EmailListItem` receives `email: Email` + `onToggle` callback; uses local `Animated.Value` for swipe-to-reveal (archive/delete).
+- `CategoryBar` updates store's `selectedCategory`; store recomputes `filteredEmails`.
+- `SearchBar` updates store's `searchQuery`; store recomputes `filteredEmails`.
+
+## States to handle
+
+| Component | Loading | Empty | Error | Edge cases |
+|----------------|---------|-------|-------|-------------------------|
+| ClassicInbox | — | ✓ | — | overscroll, pull-refresh|
+| EmailListItem | — | — | — | long press, swipe |
+| CategoryBar | — | — | — | single category, all |
+| TopNav | — | — | — | long account name |
+| SearchBar | — | — | — | clear, debounce |
+| InboxStore | ✓ | ✓ | ✓ | empty search results |
+
+## Animations
+
+1. **Header hide/reveal**: driven by scroll offset. Threshold ~50px. At scroll > threshold, translate header group up by its own height.
+2. **Swipe-to-reveal**: each `EmailListItem` wraps content in `Animated.View` with `PanResponder`.
+3. **Category switch**: cross-fade or no animation (design choice — we use instant filter in store).
diff --git a/bounty-2/README.md b/bounty-2/README.md
new file mode 100644
index 00000000..6c45afd0
--- /dev/null
+++ b/bounty-2/README.md
@@ -0,0 +1,57 @@
+# Warpspeed Bounty #2 — Classic Inbox View ($330)
+
+A React Native + TypeScript reference implementation for a traditional email inbox UI.
+
+## Features
+
+- Sticky top navigation with account switcher and route buttons
+- Scroll-based header hide/reveal for category tabs and search bar
+- Three email categories: Primary, Promotions, Updates
+- Search bar with live filtering and clear button
+- Email list with read/unread visual states
+- Swipe-to-reveal "Archive" action on email rows
+- Star/unstar and attachment indicators
+- Pull-to-refresh
+- Empty state messaging
+- MobX store for all state management
+
+## File Structure
+
+```
+components/
+ ClassicInbox.tsx — Main orchestrator with animated header
+ TopNav.tsx — Account selector + nav buttons
+ CategoryBar.tsx — Category filter tabs
+ SearchBar.tsx — Search input + filter toggle
+ EmailListItem.tsx — Single email row with swipe
+state/
+ inboxStore.ts — MobX store (accounts, emails, ui state)
+types.ts — TypeScript types
+DESIGN.md — Full component architecture
+README.md — This file
+```
+
+## Getting Started
+
+```bash
+npm install
+npx expo start
+```
+
+## Dependencies
+
+- react-native
+- mobx / mobx-react-lite
+- typescript
+
+## States Covered
+
+| State | Handling |
+|--------------|----------------------------------------|
+| Loading | Pull-to-refresh spinner via `simulateLoad` |
+| Empty | `ListEmptyComponent` with context-aware message |
+| Error | Store has `error` field (extendable) |
+| Search empty | Shows "Try a different search term." |
+| Unread | Blue dot + bold sender/subject |
+| Starred | Filled/outline star glyph toggle |
+| Swipe | PanResponder reveal + spring back |
diff --git a/bounty-2/components/CategoryBar.tsx b/bounty-2/components/CategoryBar.tsx
new file mode 100644
index 00000000..f12035e1
--- /dev/null
+++ b/bounty-2/components/CategoryBar.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
+import { observer } from 'mobx-react-lite';
+import { inboxStore } from '../state/inboxStore';
+import { Category } from '../types';
+
+const CATEGORIES: { key: Category; label: string }[] = [
+ { key: 'primary', label: 'Primary' },
+ { key: 'promotions', label: 'Promotions' },
+ { key: 'updates', label: 'Updates' },
+];
+
+const CategoryBar: React.FC = () => {
+ return (
+
+ {CATEGORIES.map((cat) => {
+ const active = inboxStore.selectedCategory === cat.key;
+ return (
+ inboxStore.setCategory(cat.key)}
+ accessibilityLabel={cat.label}
+ >
+
+ {cat.label}
+
+ {active && }
+
+ );
+ })}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ paddingHorizontal: 12,
+ backgroundColor: '#fff',
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderColor: '#e8e8e8',
+ },
+ tab: {
+ flex: 1,
+ alignItems: 'center',
+ paddingVertical: 10,
+ position: 'relative',
+ },
+ tabActive: {},
+ label: {
+ fontSize: 14,
+ fontWeight: '500',
+ color: '#666',
+ },
+ labelActive: {
+ color: '#1a73e8',
+ fontWeight: '700',
+ },
+ indicator: {
+ position: 'absolute',
+ bottom: 0,
+ height: 3,
+ width: 40,
+ backgroundColor: '#1a73e8',
+ borderRadius: 1.5,
+ },
+});
+
+export default observer(CategoryBar);
diff --git a/bounty-2/components/ClassicInbox.tsx b/bounty-2/components/ClassicInbox.tsx
new file mode 100644
index 00000000..d4ab0366
--- /dev/null
+++ b/bounty-2/components/ClassicInbox.tsx
@@ -0,0 +1,130 @@
+import React, { useCallback, useRef } from 'react';
+import {
+ View,
+ Text,
+ FlatList,
+ Animated,
+ RefreshControl,
+ StyleSheet,
+} from 'react-native';
+import { observer } from 'mobx-react-lite';
+import { inboxStore } from '../state/inboxStore';
+import { Email } from '../types';
+import TopNav from './TopNav';
+import CategoryBar from './CategoryBar';
+import SearchBar from './SearchBar';
+import EmailListItem from './EmailListItem';
+
+const HEADER_HEIGHT = 100; // approximate combined height of CategoryBar + SearchBar
+
+const ClassicInbox: React.FC = () => {
+ const scrollY = useRef(new Animated.Value(0)).current;
+ const scrollOffset = useRef(0);
+
+ const headerTranslate = scrollY.interpolate({
+ inputRange: [0, HEADER_HEIGHT],
+ outputRange: [0, -HEADER_HEIGHT],
+ extrapolate: 'clamp',
+ });
+
+ const onScroll = useCallback(
+ Animated.event(
+ [{ nativeEvent: { contentOffset: { y: scrollY } } }],
+ { useNativeDriver: true },
+ ),
+ [scrollY],
+ );
+
+ const handleRefresh = useCallback(() => {
+ inboxStore.simulateLoad();
+ }, []);
+
+ const renderItem = useCallback(
+ ({ item }: { item: Email }) => ,
+ [],
+ );
+
+ const keyExtractor = useCallback((item: Email) => item.id, []);
+
+ const renderEmpty = useCallback(
+ () => (
+
+ No emails
+
+ {inboxStore.searchQuery
+ ? 'Try a different search term.'
+ : 'Nothing here yet.'}
+
+
+ ),
+ [],
+ );
+
+ return (
+
+ {/* Always-visible top nav */}
+
+
+ {/* Scroll-hiding header group */}
+
+
+
+
+
+ {/* Email list */}
+
+ }
+ showsVerticalScrollIndicator={false}
+ />
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#fff',
+ },
+ headerGroup: {
+ backgroundColor: '#fff',
+ zIndex: 5,
+ },
+ listContent: {
+ flexGrow: 1,
+ },
+ empty: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingTop: 80,
+ paddingHorizontal: 32,
+ },
+ emptyTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: '#333',
+ marginBottom: 8,
+ },
+ emptySub: {
+ fontSize: 14,
+ color: '#888',
+ textAlign: 'center',
+ },
+});
+
+export default observer(ClassicInbox);
diff --git a/bounty-2/components/EmailListItem.tsx b/bounty-2/components/EmailListItem.tsx
new file mode 100644
index 00000000..5804b350
--- /dev/null
+++ b/bounty-2/components/EmailListItem.tsx
@@ -0,0 +1,270 @@
+import React, { useCallback, useRef } from 'react';
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ Animated,
+ PanResponder,
+ StyleSheet,
+} from 'react-native';
+import { observer } from 'mobx-react-lite';
+import { Email } from '../types';
+import { inboxStore } from '../state/inboxStore';
+
+interface Props {
+ email: Email;
+}
+
+const SWIPE_THRESHOLD = 80;
+
+const EmailListItem: React.FC = ({ email }) => {
+ const translateX = useRef(new Animated.Value(0)).current;
+ const panY = useRef(new Animated.Value(0)).current;
+
+ const panResponder = useRef(
+ PanResponder.create({
+ onMoveShouldSetPanResponder: (_, gs) =>
+ Math.abs(gs.dx) > 10 && Math.abs(gs.dx) > Math.abs(gs.dy),
+ onPanResponderMove: (_, gs) => {
+ translateX.setValue(Math.min(0, gs.dx));
+ },
+ onPanResponderRelease: (_, gs) => {
+ if (gs.dx < -SWIPE_THRESHOLD) {
+ Animated.spring(translateX, {
+ toValue: -80,
+ useNativeDriver: true,
+ }).start();
+ } else {
+ Animated.spring(translateX, {
+ toValue: 0,
+ useNativeDriver: true,
+ }).start();
+ }
+ },
+ }),
+ ).current;
+
+ const resetSwipe = useCallback(() => {
+ Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start();
+ }, [translateX]);
+
+ const handlePress = useCallback(() => {
+ if (email.isUnread) inboxStore.markRead(email.id);
+ }, [email]);
+
+ return (
+
+ {/* Revealed actions behind the row */}
+
+
+ Archive
+
+
+
+ {/* Main row */}
+
+ inboxStore.toggleStar(email.id)}
+ accessibilityLabel={`${email.isUnread ? 'Unread' : 'Read'}: ${email.subject}`}
+ >
+
+ {/* Left: avatar + unread dot */}
+
+
+
+ {email.from.name.charAt(0).toUpperCase()}
+
+
+ {email.isUnread && }
+
+
+ {/* Center: content */}
+
+
+
+ {email.from.name}
+
+
+ {formatTime(email.timestamp)}
+
+
+
+ {email.subject}
+
+
+ {email.preview}
+
+
+
+ {/* Right: star + attachment */}
+
+ {
+ e.stopPropagation?.();
+ inboxStore.toggleStar(email.id);
+ }}
+ accessibilityLabel={email.isStarred ? 'Unstar' : 'Star'}
+ >
+
+ {email.isStarred ? '★' : '☆'}
+
+
+ {email.hasAttachment && 📎}
+
+
+
+
+
+ );
+};
+
+function formatTime(ts: number): string {
+ const d = new Date(ts);
+ const now = new Date();
+ const diffDays = Math.floor(
+ (now.getTime() - d.getTime()) / 86_400_000,
+ );
+ if (diffDays === 0) {
+ return d.toLocaleTimeString(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ }
+ if (diffDays === 1) return 'Yesterday';
+ if (diffDays < 7) {
+ return d.toLocaleDateString(undefined, { weekday: 'short' });
+ }
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+}
+
+const styles = StyleSheet.create({
+ wrapper: {
+ position: 'relative',
+ },
+ actionsRow: {
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ bottom: 0,
+ width: 80,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ actionArchive: {
+ backgroundColor: '#1a73e8',
+ borderRadius: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ },
+ actionText: {
+ color: '#fff',
+ fontWeight: '600',
+ fontSize: 13,
+ },
+ row: {
+ backgroundColor: '#fff',
+ },
+ inner: {
+ flexDirection: 'row',
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderColor: '#eee',
+ },
+ leftCol: {
+ alignItems: 'center',
+ marginRight: 12,
+ width: 40,
+ },
+ avatar: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: '#ddd',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ avatarUnread: {
+ backgroundColor: '#1a73e8',
+ },
+ avatarText: {
+ color: '#fff',
+ fontWeight: '600',
+ fontSize: 16,
+ },
+ unreadDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: '#1a73e8',
+ marginTop: 4,
+ },
+ content: {
+ flex: 1,
+ marginRight: 8,
+ },
+ topRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ sender: {
+ fontSize: 15,
+ color: '#444',
+ flex: 1,
+ marginRight: 8,
+ },
+ senderUnread: {
+ fontWeight: '700',
+ color: '#111',
+ },
+ time: {
+ fontSize: 12,
+ color: '#888',
+ },
+ subject: {
+ fontSize: 14,
+ color: '#222',
+ marginTop: 2,
+ },
+ subjectUnread: {
+ fontWeight: '700',
+ color: '#000',
+ },
+ preview: {
+ fontSize: 13,
+ color: '#777',
+ marginTop: 2,
+ lineHeight: 18,
+ },
+ rightCol: {
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ minWidth: 28,
+ },
+ star: {
+ fontSize: 18,
+ color: '#f5a623',
+ marginBottom: 4,
+ },
+ clip: {
+ fontSize: 14,
+ },
+});
+
+export default observer(EmailListItem);
diff --git a/bounty-2/components/SearchBar.tsx b/bounty-2/components/SearchBar.tsx
new file mode 100644
index 00000000..867e615e
--- /dev/null
+++ b/bounty-2/components/SearchBar.tsx
@@ -0,0 +1,108 @@
+import React, { useCallback, useRef } from 'react';
+import {
+ View,
+ TextInput,
+ TouchableOpacity,
+ StyleSheet,
+ Text,
+} from 'react-native';
+import { observer } from 'mobx-react-lite';
+import { inboxStore } from '../state/inboxStore';
+
+const SearchBar: React.FC = () => {
+ const inputRef = useRef(null);
+
+ const handleChange = useCallback((text: string) => {
+ inboxStore.setSearchQuery(text);
+ }, []);
+
+ const handleClear = useCallback(() => {
+ inboxStore.setSearchQuery('');
+ inputRef.current?.focus();
+ }, []);
+
+ return (
+
+
+ 🔍
+
+ {inboxStore.searchQuery.length > 0 && (
+
+ ✕
+
+ )}
+ inboxStore.toggleSearch()}
+ style={styles.filterBtn}
+ accessibilityLabel="Toggle filter"
+ >
+ ⏷ Filter
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ backgroundColor: '#fff',
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderColor: '#e8e8e8',
+ },
+ inputRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#f1f3f4',
+ borderRadius: 10,
+ paddingHorizontal: 10,
+ height: 40,
+ },
+ icon: {
+ fontSize: 16,
+ marginRight: 6,
+ },
+ input: {
+ flex: 1,
+ fontSize: 15,
+ color: '#222',
+ paddingVertical: 0,
+ },
+ clearBtn: {
+ padding: 4,
+ marginRight: 4,
+ },
+ clearText: {
+ fontSize: 14,
+ color: '#888',
+ },
+ filterBtn: {
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ backgroundColor: '#e8e8e8',
+ borderRadius: 6,
+ },
+ filterText: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: '#444',
+ },
+});
+
+export default observer(SearchBar);
diff --git a/bounty-2/components/TopNav.tsx b/bounty-2/components/TopNav.tsx
new file mode 100644
index 00000000..704165d2
--- /dev/null
+++ b/bounty-2/components/TopNav.tsx
@@ -0,0 +1,210 @@
+import React, { useState } from 'react';
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ Modal,
+ FlatList,
+ StyleSheet,
+ StatusBar,
+} from 'react-native';
+import { observer } from 'mobx-react-lite';
+import { inboxStore } from '../state/inboxStore';
+import { NavigationRoute } from '../types';
+
+const ROUTES: NavigationRoute[] = ['flow', 'dashboard', 'classic', 'compose'];
+const ROUTE_LABELS: Record = {
+ flow: 'Flow',
+ dashboard: 'Dashboard',
+ classic: 'Classic',
+ compose: 'Compose',
+};
+
+const TopNav: React.FC = () => {
+ const [showAccounts, setShowAccounts] = useState(false);
+
+ return (
+
+
+
+ {/* Left: account selector */}
+ setShowAccounts(true)}
+ accessibilityLabel="Switch account"
+ >
+
+
+ {inboxStore.selectedAccount.displayName.charAt(0).toUpperCase()}
+
+
+
+ {inboxStore.selectedAccount.displayName}
+
+ ▾
+
+
+ {/* Right: nav buttons */}
+
+ {ROUTES.map((route) => {
+ const active = inboxStore.activeRoute === route;
+ return (
+ inboxStore.setRoute(route)}
+ accessibilityLabel={ROUTE_LABELS[route]}
+ >
+
+ {ROUTE_LABELS[route]}
+
+
+ );
+ })}
+
+
+ {/* Account switcher modal */}
+ setShowAccounts(false)}
+ >
+ setShowAccounts(false)}
+ >
+
+ Switch Account
+ item.id}
+ renderItem={({ item }) => (
+ {
+ inboxStore.setAccount(item.id);
+ setShowAccounts(false);
+ }}
+ >
+ {item.displayName}
+ {item.email}
+
+ )}
+ />
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ backgroundColor: '#fff',
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderColor: '#e0e0e0',
+ zIndex: 10,
+ },
+ accountBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ maxWidth: 160,
+ },
+ avatar: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: '#1a73e8',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: 8,
+ },
+ avatarText: {
+ color: '#fff',
+ fontWeight: '600',
+ fontSize: 14,
+ },
+ accountName: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: '#222',
+ maxWidth: 100,
+ },
+ chevron: {
+ marginLeft: 4,
+ fontSize: 12,
+ color: '#666',
+ },
+ navRow: {
+ flexDirection: 'row',
+ },
+ navBtn: {
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ borderRadius: 6,
+ marginLeft: 4,
+ },
+ navBtnActive: {
+ backgroundColor: '#e8f0fe',
+ },
+ navLabel: {
+ fontSize: 13,
+ fontWeight: '500',
+ color: '#555',
+ },
+ navLabelActive: {
+ color: '#1a73e8',
+ fontWeight: '600',
+ },
+ overlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.35)',
+ justifyContent: 'center',
+ paddingHorizontal: 40,
+ },
+ modal: {
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ padding: 16,
+ maxHeight: 300,
+ },
+ modalTitle: {
+ fontSize: 16,
+ fontWeight: '700',
+ marginBottom: 12,
+ color: '#222',
+ },
+ accountRow: {
+ paddingVertical: 12,
+ paddingHorizontal: 8,
+ borderRadius: 8,
+ },
+ accountRowActive: {
+ backgroundColor: '#e8f0fe',
+ },
+ accountRowText: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: '#222',
+ },
+ accountRowEmail: {
+ fontSize: 13,
+ color: '#666',
+ marginTop: 2,
+ },
+});
+
+export default observer(TopNav);
diff --git a/bounty-2/state/inboxStore.ts b/bounty-2/state/inboxStore.ts
new file mode 100644
index 00000000..4eeae85d
--- /dev/null
+++ b/bounty-2/state/inboxStore.ts
@@ -0,0 +1,98 @@
+import { makeAutoObservable, computed } from 'mobx';
+import { Account, Category, Email, NavigationRoute } from '../types';
+
+const MOCK_ACCOUNTS: Account[] = [
+ { id: '1', email: 'alice@example.com', displayName: 'Alice Chen' },
+ { id: '2', email: 'bob@work.com', displayName: 'Bob Martinez' },
+];
+
+const MOCK_EMAILS: Email[] = Array.from({ length: 30 }, (_, i) => ({
+ id: `email-${i}`,
+ from: {
+ name: `Sender ${i}`,
+ email: `sender${i}@example.com`,
+ },
+ subject: `Email subject ${i} — Lorem ipsum dolor sit amet`,
+ preview: `This is a preview of email number ${i}. It goes up to two lines at most.`,
+ body: `Full body of email ${i}.`,
+ category: (['primary', 'promotions', 'updates'] as Category)[i % 3],
+ isUnread: i < 8,
+ isStarred: i % 5 === 0,
+ hasAttachment: i % 4 === 0,
+ timestamp: Date.now() - i * 86_400_000,
+}));
+
+export class InboxStore {
+ accounts: Account[] = MOCK_ACCOUNTS;
+ selectedAccountId: string = MOCK_ACCOUNTS[0].id;
+ activeRoute: NavigationRoute = 'classic';
+ selectedCategory: Category = 'primary';
+ emails: Email[] = MOCK_EMAILS;
+ searchQuery: string = '';
+ isSearchVisible: boolean = false;
+ isLoading: boolean = false;
+ error: string | null = null;
+
+ constructor() {
+ makeAutoObservable(this);
+ }
+
+ get selectedAccount(): Account {
+ return this.accounts.find((a) => a.id === this.selectedAccountId)!
+ }
+
+ get filteredEmails(): Email[] {
+ let list = this.emails.filter((e) => e.category === this.selectedCategory);
+ const q = this.searchQuery.trim().toLowerCase();
+ if (q) {
+ list = list.filter(
+ (e) =>
+ e.subject.toLowerCase().includes(q) ||
+ e.preview.toLowerCase().includes(q) ||
+ e.from.name.toLowerCase().includes(q),
+ );
+ }
+ return list;
+ }
+
+ setAccount = (id: string) => {
+ this.selectedAccountId = id;
+ };
+
+ setRoute = (route: NavigationRoute) => {
+ this.activeRoute = route;
+ };
+
+ setCategory = (cat: Category) => {
+ this.selectedCategory = cat;
+ };
+
+ toggleStar = (id: string) => {
+ const email = this.emails.find((e) => e.id === id);
+ if (email) email.isStarred = !email.isStarred;
+ };
+
+ markRead = (id: string) => {
+ const email = this.emails.find((e) => e.id === id);
+ if (email) email.isUnread = false;
+ };
+
+ setSearchQuery = (q: string) => {
+ this.searchQuery = q;
+ };
+
+ toggleSearch = () => {
+ this.isSearchVisible = !this.isSearchVisible;
+ if (!this.isSearchVisible) this.searchQuery = '';
+ };
+
+ simulateLoad = () => {
+ this.isLoading = true;
+ this.error = null;
+ setTimeout(() => {
+ this.isLoading = false;
+ }, 800);
+ };
+}
+
+export const inboxStore = new InboxStore();
diff --git a/bounty-2/types.ts b/bounty-2/types.ts
new file mode 100644
index 00000000..0a6acc75
--- /dev/null
+++ b/bounty-2/types.ts
@@ -0,0 +1,35 @@
+export type Category = 'primary' | 'promotions' | 'updates';
+
+export type NavigationRoute = 'flow' | 'dashboard' | 'classic' | 'compose';
+
+export interface Account {
+ id: string;
+ email: string;
+ displayName: string;
+ avatarUri?: string;
+}
+
+export interface Email {
+ id: string;
+ from: { name: string; email: string; avatarUri?: string };
+ subject: string;
+ preview: string;
+ body: string;
+ category: Category;
+ isUnread: boolean;
+ isStarred: boolean;
+ hasAttachment: boolean;
+ timestamp: number; // unix ms
+}
+
+export interface InboxState {
+ accounts: Account[];
+ selectedAccountId: string;
+ activeRoute: NavigationRoute;
+ selectedCategory: Category;
+ emails: Email[];
+ searchQuery: string;
+ isSearchVisible: boolean;
+ isLoading: boolean;
+ error: string | null;
+}