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; + } +}