diff --git a/.claude/skills/react/SKILL.md b/.claude/skills/react/SKILL.md
index 5f00b4d..2ea8207 100644
--- a/.claude/skills/react/SKILL.md
+++ b/.claude/skills/react/SKILL.md
@@ -29,6 +29,49 @@ const handlePress = useCallback((id) => router.push(`/coin/${id}`), []);
**Exception**: only add manual memoization if you have a profiler trace proving it's needed.
+## Library Memoization Recommendations (TanStack Query, etc.)
+
+Some libraries recommend wrapping callbacks in `useCallback` for referential stability.
+A common example is TanStack Query's `select` option:
+
+```typescript
+// What TanStack docs recommend in projects WITHOUT React Compiler:
+useQuery({
+ queryKey: ['coins'],
+ queryFn: fetchCoins,
+ select: useCallback((data) => data.filter((c) => c.rank <= 10), []),
+});
+
+// ✅ What to do in THIS project (React Compiler enabled):
+useQuery({
+ queryKey: ['coins'],
+ queryFn: fetchCoins,
+ select: (data) => data.filter((c) => c.rank <= 10),
+});
+```
+
+React Compiler automatically stabilizes the function identity — manual `useCallback` is redundant.
+**If this project ever disables React Compiler**, add `useCallback` back to all `select`, `onSuccess`,
+and similar library callbacks that depend on referential stability.
+
+## Arrow Functions for Component Helpers (REQUIRED)
+
+Use arrow functions for all helper functions defined inside or alongside components — render callbacks, event handlers, formatters. Never use `function` declarations for these.
+
+```typescript
+// ✅ Arrow function for render callbacks and helpers
+const renderItem = ({ item }: { item: Coin }) => ;
+
+const handlePress = (id: string) => router.push(`/coin/${id}`);
+
+// ❌ NEVER: function declaration for component helpers
+function renderItem({ item }: { item: Coin }) {
+ return ;
+}
+```
+
+**Exception**: top-level exported components may use either style — this rule applies to helpers and callbacks, not to the component function itself.
+
## Imports (REQUIRED)
```typescript
diff --git a/.github/assets/home-preview.png b/.github/assets/home-preview.png
new file mode 100644
index 0000000..0a22d01
Binary files /dev/null and b/.github/assets/home-preview.png differ
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
new file mode 100644
index 0000000..5831efb
--- /dev/null
+++ b/.github/workflows/claude-code-review.yml
@@ -0,0 +1,45 @@
+name: Claude Code Review
+
+on:
+ pull_request:
+ types: [opened, synchronize, ready_for_review, reopened]
+ # Optional: Only run on specific file changes
+ # paths:
+ # - "src/**/*.ts"
+ # - "src/**/*.tsx"
+ # - "src/**/*.js"
+ # - "src/**/*.jsx"
+
+jobs:
+ claude-review:
+ # Optional: Filter by PR author
+ # if: |
+ # github.event.pull_request.user.login == 'external-contributor' ||
+ # github.event.pull_request.user.login == 'new-developer' ||
+ # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
+
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code Review
+ id: claude-review
+ uses: anthropics/claude-code-action@v1
+ with:
+ allowed_bots: "*"
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
+ plugins: 'code-review@claude-code-plugins'
+ prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
index faa1c6c..7ef16ec 100644
--- a/.github/workflows/claude.yml
+++ b/.github/workflows/claude.yml
@@ -1,39 +1,51 @@
- name: Claude Code
-
- on:
- issue_comment:
- types: [created]
- pull_request_review_comment:
- types: [created]
- issues:
- types: [opened, assigned]
- pull_request_review:
- types: [submitted]
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+ actions: read # Required for Claude to read CI results on PRs
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@v1
+ with:
+ allowed_bots: "*"
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # This is an optional setting that allows Claude to read CI results on PRs
+ additional_permissions: |
+ actions: read
+
+ # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
+ # prompt: 'Update the pull request description to include a summary of changes.'
+
+ # Optional: Add claude_args to customize behavior and configuration
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+ # claude_args: '--allowed-tools Bash(gh pr:*)'
- jobs:
- claude:
- if: |
- (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
- (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
- (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
- (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
- runs-on: ubuntu-latest
- permissions:
- contents: write
- issues: write
- pull-requests: write
- id-token: write
- actions: read # Required for Claude to read CI results on PRs
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- fetch-depth: 0
-
- - name: Run Claude Code
- id: claude
- uses: anthropics/claude-code-action@beta
- with:
- anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx
index 71518f9..40c5657 100644
--- a/app/(tabs)/explore.tsx
+++ b/app/(tabs)/explore.tsx
@@ -4,8 +4,8 @@ import { Platform, StyleSheet } from 'react-native';
import { Collapsible } from '@/components/ui/collapsible';
import { ExternalLink } from '@/components/external-link';
import ParallaxScrollView from '@/components/parallax-scroll-view';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
+import { Text } from '@/components/text';
+import { View } from '@/components/view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Fonts } from '@/constants/theme';
@@ -21,76 +21,76 @@ export default function TabTwoScreen() {
style={styles.headerImage}
/>
}>
-
-
+
Explore
-
-
- This app includes example code to help you get started.
+
+
+ This app includes example code to help you get started.
-
+
This app has two screens:{' '}
- app/(tabs)/index.tsx and{' '}
- app/(tabs)/explore.tsx
-
-
- The layout file in app/(tabs)/_layout.tsx{' '}
+ app/(tabs)/index.tsx and{' '}
+ app/(tabs)/explore.tsx
+
+
+ The layout file in app/(tabs)/_layout.tsx{' '}
sets up the tab navigator.
-
+
- Learn more
+ Learn more
-
+
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
- w in the terminal running this project.
-
+ w in the terminal running this project.
+
-
- For static images, you can use the @2x and{' '}
- @3x suffixes to provide files for
+
+ For static images, you can use the @2x and{' '}
+ @3x suffixes to provide files for
different screen densities
-
+
- Learn more
+ Learn more
-
+
This template has light and dark mode support. The{' '}
- useColorScheme() hook lets you inspect
+ useColorScheme() hook lets you inspect
what the user's current color scheme is, and so you can adjust UI colors accordingly.
-
+
- Learn more
+ Learn more
-
+
This template includes an example of an animated component. The{' '}
- components/HelloWave.tsx component uses
+ components/HelloWave.tsx component uses
the powerful{' '}
-
+
react-native-reanimated
- {' '}
+ {' '}
library to create a waving hand animation.
-
+
{Platform.select({
ios: (
-
- The components/ParallaxScrollView.tsx{' '}
+
+ The components/ParallaxScrollView.tsx{' '}
component provides a parallax effect for the header image.
-
+
),
})}
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 786b736..ab43b40 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,98 +1,79 @@
-import { Image } from 'expo-image';
-import { Platform, StyleSheet } from 'react-native';
+import { FlatList, StyleSheet, useWindowDimensions } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { HelloWave } from '@/components/hello-wave';
-import ParallaxScrollView from '@/components/parallax-scroll-view';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { Link } from 'expo-router';
+import { Text } from "@/components/text";
+import { View } from "@/components/view";
+import { Colors } from "@/constants/theme";
+import { CoinRow } from "@/features/home/components/coin-row";
+import { HomeHero } from "@/features/home/components/home-hero";
+import { ListSectionHeader } from "@/features/home/components/list-section-header";
+import { APP_NAME, BTC_SYMBOL, COIN_ITEM_HEIGHT } from "@/features/home/config";
+import { useColorScheme } from "@/hooks/use-color-scheme";
+import { useCryptoListings } from "@/hooks/use-crypto-listings";
+import type { CmcCoin } from "@/types/cmc";
+
+function keyExtractor(item: CmcCoin) {
+ return String(item.id);
+}
+
+function getItemLayout(
+ _: ArrayLike | null | undefined,
+ index: number,
+) {
+ return {
+ length: COIN_ITEM_HEIGHT,
+ offset: COIN_ITEM_HEIGHT * index,
+ index,
+ };
+}
export default function HomeScreen() {
- return (
-
- }>
-
- Welcome!
-
-
-
- Step 1: Try it
-
- Edit app/(tabs)/index.tsx to see changes.
- Press{' '}
-
- {Platform.select({
- ios: 'cmd + d',
- android: 'cmd + m',
- web: 'F12',
- })}
- {' '}
- to open developer tools.
-
-
-
-
-
- Step 2: Explore
-
-
-
- alert('Action pressed')} />
- alert('Share pressed')}
- />
-
- alert('Delete pressed')}
- />
-
-
-
+ const { height } = useWindowDimensions();
+ const insets = useSafeAreaInsets();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? "light"];
+ const { data: coins, isLoading, isError } = useCryptoListings();
+
+ const btc = coins?.find((c) => c.symbol === BTC_SYMBOL);
-
- {`Tap the Explore tab to learn more about what's included in this starter app.`}
-
-
-
- Step 3: Get a fresh start
-
- {`When you're ready, run `}
- npm run reset-project to get a fresh{' '}
- app directory. This will move the current{' '}
- app to{' '}
- app-example.
-
-
-
+ const renderCoin = ({ item }: { item: CmcCoin }) => ;
+
+ const listHeader = (
+
+
+ {APP_NAME}
+
+
+
+
+ );
+
+ return (
+
+ listHeader}
+ getItemLayout={getItemLayout}
+ showsVerticalScrollIndicator={false}
+ contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
+ />
+
);
}
const styles = StyleSheet.create({
- titleContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- },
- stepContainer: {
- gap: 8,
- marginBottom: 8,
+ container: {
+ flex: 1,
},
- reactLogo: {
- height: 178,
- width: 290,
- bottom: 0,
- left: 0,
- position: 'absolute',
+ header: {
+ alignItems: "center",
+ paddingBottom: 8,
},
});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index f518c9b..70a37be 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,9 +1,11 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
+import { QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/use-color-scheme';
+import { queryClient } from '@/lib/query-client';
export const unstable_settings = {
anchor: '(tabs)',
@@ -13,12 +15,14 @@ export default function RootLayout() {
const colorScheme = useColorScheme();
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
}
diff --git a/app/modal.tsx b/app/modal.tsx
index 6dfbc1a..06cfd64 100644
--- a/app/modal.tsx
+++ b/app/modal.tsx
@@ -1,17 +1,17 @@
import { Link } from 'expo-router';
import { StyleSheet } from 'react-native';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
+import { Text } from '@/components/text';
+import { View } from '@/components/view';
export default function ModalScreen() {
return (
-
- This is a modal
+
+ This is a modal
- Go to home screen
+ Go to home screen
-
+
);
}
diff --git a/components/parallax-scroll-view.tsx b/components/parallax-scroll-view.tsx
index 6f674a7..459fcb1 100644
--- a/components/parallax-scroll-view.tsx
+++ b/components/parallax-scroll-view.tsx
@@ -7,7 +7,7 @@ import Animated, {
useScrollOffset,
} from 'react-native-reanimated';
-import { ThemedView } from '@/components/themed-view';
+import { View } from '@/components/view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color';
@@ -57,7 +57,7 @@ export default function ParallaxScrollView({
]}>
{headerImage}
- {children}
+ {children}
);
}
diff --git a/components/themed-text.tsx b/components/text.tsx
similarity index 83%
rename from components/themed-text.tsx
rename to components/text.tsx
index d79d0a1..eecda06 100644
--- a/components/themed-text.tsx
+++ b/components/text.tsx
@@ -1,14 +1,16 @@
-import { StyleSheet, Text, type TextProps } from 'react-native';
+import { StyleSheet, Text as RNText, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
+export type TextVariant = 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
+
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
- type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
+ type?: TextVariant;
};
-export function ThemedText({
+export function Text({
style,
lightColor,
darkColor,
@@ -18,7 +20,7 @@ export function ThemedText({
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
-
+
setIsOpen((value) => !value)}
@@ -25,10 +25,10 @@ export function Collapsible({ children, title }: PropsWithChildren & { title: st
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
- {title}
+ {title}
- {isOpen && {children}}
-
+ {isOpen && {children}}
+
);
}
diff --git a/components/themed-view.tsx b/components/view.tsx
similarity index 52%
rename from components/themed-view.tsx
rename to components/view.tsx
index 6f181d8..6210a62 100644
--- a/components/themed-view.tsx
+++ b/components/view.tsx
@@ -1,4 +1,4 @@
-import { View, type ViewProps } from 'react-native';
+import { View as RNView, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
@@ -7,8 +7,8 @@ export type ThemedViewProps = ViewProps & {
darkColor?: string;
};
-export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
+export function View({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
- return ;
+ return ;
}
diff --git a/features/home/components/coin-row.tsx b/features/home/components/coin-row.tsx
new file mode 100644
index 0000000..e870182
--- /dev/null
+++ b/features/home/components/coin-row.tsx
@@ -0,0 +1,64 @@
+import { StyleSheet } from 'react-native';
+
+import { View } from '@/components/view';
+
+import { Text } from '@/components/text';
+import { CHANGE_COLORS, COIN_ITEM_HEIGHT } from '@/features/home/config';
+import { formatChange, formatPrice } from '@/features/home/utils';
+import type { CmcCoin } from '@/types/cmc';
+
+interface CoinRowProps {
+ coin: CmcCoin;
+}
+
+export function CoinRow({ coin }: CoinRowProps) {
+ const change = coin.quote.USD.percent_change_24h;
+ const changeColor = change >= 0 ? CHANGE_COLORS.positive : CHANGE_COLORS.negative;
+
+ return (
+
+ #{coin.cmc_rank}
+
+ {coin.name}
+ {coin.symbol}
+
+
+ {formatPrice(coin.quote.USD.price)}
+
+ {formatChange(change)}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ coinRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ height: COIN_ITEM_HEIGHT,
+ gap: 12,
+ },
+ rank: {
+ width: 36,
+ fontSize: 13,
+ opacity: 0.4,
+ },
+ coinInfo: {
+ flex: 1,
+ gap: 2,
+ },
+ symbol: {
+ fontSize: 13,
+ opacity: 0.5,
+ },
+ coinPrices: {
+ alignItems: 'flex-end',
+ gap: 2,
+ },
+ change: {
+ fontSize: 13,
+ fontWeight: '600',
+ },
+});
diff --git a/features/home/components/home-hero.tsx b/features/home/components/home-hero.tsx
new file mode 100644
index 0000000..d00407a
--- /dev/null
+++ b/features/home/components/home-hero.tsx
@@ -0,0 +1,69 @@
+import { StyleSheet } from 'react-native';
+
+import { View } from '@/components/view';
+
+import { Text } from '@/components/text';
+import { CHANGE_COLORS } from '@/features/home/config';
+import { formatChange, formatPrice } from '@/features/home/utils';
+import type { CmcCoin } from '@/types/cmc';
+
+interface HomeHeroProps {
+ coin: CmcCoin | undefined;
+ isLoading: boolean;
+ isError: boolean;
+ height: number;
+}
+
+export function HomeHero({ coin, isLoading, isError, height }: HomeHeroProps) {
+ if (isLoading) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+ Failed to load
+
+ );
+ }
+
+ if (!coin) return null;
+
+ const change = coin.quote.USD.percent_change_24h;
+ const changeColor = change >= 0 ? CHANGE_COLORS.positive : CHANGE_COLORS.negative;
+
+ return (
+
+ {coin.name} ({coin.symbol})
+ {formatPrice(coin.quote.USD.price)}
+
+ {formatChange(change)} today
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ hero: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4,
+ },
+ label: {
+ fontSize: 16,
+ opacity: 0.6,
+ },
+ price: {
+ fontSize: 48,
+ fontWeight: 'bold',
+ lineHeight: 56,
+ },
+ change: {
+ fontSize: 18,
+ fontWeight: '600',
+ },
+});
diff --git a/features/home/components/list-section-header.tsx b/features/home/components/list-section-header.tsx
new file mode 100644
index 0000000..968aff1
--- /dev/null
+++ b/features/home/components/list-section-header.tsx
@@ -0,0 +1,30 @@
+import { StyleSheet, View } from 'react-native';
+
+import { Text } from '@/components/text';
+
+interface ListSectionHeaderProps {
+ borderColor: string;
+}
+
+export function ListSectionHeader({ borderColor }: ListSectionHeaderProps) {
+ return (
+
+ All Cryptos
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ label: {
+ fontSize: 13,
+ fontWeight: '600',
+ opacity: 0.5,
+ textTransform: 'uppercase',
+ letterSpacing: 0.8,
+ },
+});
diff --git a/features/home/config.ts b/features/home/config.ts
new file mode 100644
index 0000000..96ef0f7
--- /dev/null
+++ b/features/home/config.ts
@@ -0,0 +1,10 @@
+export const APP_NAME = 'CryptoTracker';
+export const BTC_SYMBOL = 'BTC';
+export const COIN_ITEM_HEIGHT = 64;
+
+export const CHANGE_COLORS = {
+ positive: '#22c55e',
+ negative: '#ef4444',
+} as const;
+
+export type ChangeColor = (typeof CHANGE_COLORS)[keyof typeof CHANGE_COLORS];
diff --git a/features/home/utils.ts b/features/home/utils.ts
new file mode 100644
index 0000000..b92512d
--- /dev/null
+++ b/features/home/utils.ts
@@ -0,0 +1,12 @@
+export function formatPrice(price: number): string {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ maximumFractionDigits: 2,
+ }).format(price);
+}
+
+export function formatChange(change: number): string {
+ const sign = change >= 0 ? '+' : '';
+ return `${sign}${change.toFixed(2)}%`;
+}
diff --git a/hooks/use-crypto-listings.ts b/hooks/use-crypto-listings.ts
new file mode 100644
index 0000000..15b82dc
--- /dev/null
+++ b/hooks/use-crypto-listings.ts
@@ -0,0 +1,15 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { fetchCryptoListings } from '@/lib/cmc-client';
+
+const QUERY_KEYS = {
+ LISTINGS: ['crypto', 'listings'],
+} as const;
+
+export function useCryptoListings() {
+ return useQuery({
+ queryKey: QUERY_KEYS.LISTINGS,
+ queryFn: () => fetchCryptoListings(100),
+ select: (response) => response.data,
+ });
+}
diff --git a/lib/cmc-client.ts b/lib/cmc-client.ts
new file mode 100644
index 0000000..8aa9cb1
--- /dev/null
+++ b/lib/cmc-client.ts
@@ -0,0 +1,26 @@
+import type { CmcListingsResponse } from '@/types/cmc';
+
+const CMC_BASE_URL = 'https://pro-api.coinmarketcap.com';
+const CMC_API_KEY = process.env.EXPO_PUBLIC_CMC_API_KEY;
+
+if (!CMC_API_KEY) {
+ throw new Error('[cmc-client] EXPO_PUBLIC_CMC_API_KEY is not defined. Add it to your .env file.');
+}
+
+const getCmcHeaders = (): HeadersInit => ({
+ 'X-CMC_PRO_API_KEY': CMC_API_KEY,
+ Accept: 'application/json',
+});
+
+export const fetchCryptoListings = async (limit = 100): Promise => {
+ const url = new URL(`${CMC_BASE_URL}/v1/cryptocurrency/listings/latest`);
+ url.searchParams.set('limit', String(limit));
+
+ const response = await fetch(url.toString(), { headers: getCmcHeaders() });
+
+ if (!response.ok) {
+ throw new Error(`CMC API error: ${response.status} ${response.statusText}`);
+ }
+
+ return response.json() as Promise;
+};
diff --git a/lib/query-client.ts b/lib/query-client.ts
new file mode 100644
index 0000000..b73e800
--- /dev/null
+++ b/lib/query-client.ts
@@ -0,0 +1,12 @@
+import { QueryClient } from '@tanstack/react-query';
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 60, // 1 minute
+ gcTime: 1000 * 60 * 10, // 10 minutes
+ retry: 2,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
diff --git a/package-lock.json b/package-lock.json
index 378cd7b..dcfccc6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
+ "@tanstack/react-query": "^5.95.2",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
@@ -2893,6 +2894,30 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
+ "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
+ "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
+ "dependencies": {
+ "@tanstack/query-core": "5.95.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -13862,6 +13887,19 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "@tanstack/query-core": {
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
+ "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="
+ },
+ "@tanstack/react-query": {
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
+ "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
+ "requires": {
+ "@tanstack/query-core": "5.95.2"
+ }
+ },
"@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
diff --git a/package.json b/package.json
index d5e3291..8f9076b 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
+ "@tanstack/react-query": "^5.95.2",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
@@ -31,17 +32,17 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
- "react-native-worklets": "0.5.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
- "react-native-web": "~0.21.0"
+ "react-native-web": "~0.21.0",
+ "react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/react": "~19.1.0",
- "typescript": "~5.9.2",
"eslint": "^9.25.0",
- "eslint-config-expo": "~10.0.0"
+ "eslint-config-expo": "~10.0.0",
+ "typescript": "~5.9.2"
},
"private": true
}
diff --git a/types/cmc.ts b/types/cmc.ts
new file mode 100644
index 0000000..7f35ef4
--- /dev/null
+++ b/types/cmc.ts
@@ -0,0 +1,40 @@
+export interface CmcStatus {
+ timestamp: string;
+ error_code: number;
+ error_message: string | null;
+ elapsed: number;
+ credit_count: number;
+}
+
+export interface CmcQuoteUsd {
+ price: number;
+ volume_24h: number;
+ percent_change_1h: number;
+ percent_change_24h: number;
+ percent_change_7d: number;
+ market_cap: number;
+ last_updated: string;
+}
+
+export interface CmcCoinQuote {
+ USD: CmcQuoteUsd;
+}
+
+export interface CmcCoin {
+ id: number;
+ name: string;
+ symbol: string;
+ slug: string;
+ cmc_rank: number;
+ circulating_supply: number;
+ total_supply: number;
+ max_supply: number | null;
+ last_updated: string;
+ date_added: string;
+ quote: CmcCoinQuote;
+}
+
+export interface CmcListingsResponse {
+ status: CmcStatus;
+ data: CmcCoin[];
+}
diff --git a/types/env.d.ts b/types/env.d.ts
new file mode 100644
index 0000000..6fff4d0
--- /dev/null
+++ b/types/env.d.ts
@@ -0,0 +1,5 @@
+declare namespace NodeJS {
+ interface ProcessEnv {
+ readonly EXPO_PUBLIC_CMC_API_KEY: string;
+ }
+}