@sudobility/ratelimit-components-rn 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ import { View, Text, Pressable } from 'react-native';
2
+ import { cn } from '@sudobility/components-rn';
3
+ import type {
4
+ UsageDashboardProps,
5
+ UsageBarConfig,
6
+ UsageBarColor,
7
+ } from './types';
8
+
9
+ /**
10
+ * Get color classes based on usage percentage
11
+ */
12
+ function getUsageColor(
13
+ current: number,
14
+ limit: number,
15
+ colorOverride?: UsageBarColor
16
+ ): {
17
+ bg: string;
18
+ text: string;
19
+ } {
20
+ if (colorOverride) {
21
+ const colorMap: Record<UsageBarColor, { bg: string; text: string }> = {
22
+ green: { bg: 'bg-green-500', text: 'text-green-600 dark:text-green-400' },
23
+ yellow: {
24
+ bg: 'bg-yellow-500',
25
+ text: 'text-yellow-600 dark:text-yellow-400',
26
+ },
27
+ orange: {
28
+ bg: 'bg-orange-500',
29
+ text: 'text-orange-600 dark:text-orange-400',
30
+ },
31
+ red: { bg: 'bg-red-500', text: 'text-red-600 dark:text-red-400' },
32
+ blue: { bg: 'bg-blue-500', text: 'text-blue-600 dark:text-blue-400' },
33
+ gray: { bg: 'bg-gray-500', text: 'text-gray-600 dark:text-gray-400' },
34
+ };
35
+ return colorMap[colorOverride];
36
+ }
37
+
38
+ const percentage = limit > 0 ? (current / limit) * 100 : 0;
39
+
40
+ if (percentage >= 90) {
41
+ return { bg: 'bg-red-500', text: 'text-red-600 dark:text-red-400' };
42
+ } else if (percentage >= 75) {
43
+ return {
44
+ bg: 'bg-orange-500',
45
+ text: 'text-orange-600 dark:text-orange-400',
46
+ };
47
+ } else if (percentage >= 50) {
48
+ return {
49
+ bg: 'bg-yellow-500',
50
+ text: 'text-yellow-600 dark:text-yellow-400',
51
+ };
52
+ } else {
53
+ return { bg: 'bg-green-500', text: 'text-green-600 dark:text-green-400' };
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Single usage bar component
59
+ */
60
+ interface UsageBarProps {
61
+ config: UsageBarConfig;
62
+ showPercentage?: boolean;
63
+ showRemaining?: boolean;
64
+ onPress?: () => void;
65
+ }
66
+
67
+ function UsageBar({
68
+ config,
69
+ showPercentage = true,
70
+ showRemaining = true,
71
+ onPress,
72
+ }: UsageBarProps) {
73
+ const { label, current, limit, subtitle, colorOverride } = config;
74
+ const percentage = limit > 0 ? Math.min((current / limit) * 100, 100) : 0;
75
+ const remaining = Math.max(limit - current, 0);
76
+ const colors = getUsageColor(current, limit, colorOverride);
77
+
78
+ const content = (
79
+ <View className='mb-4'>
80
+ {/* Header row */}
81
+ <View className='flex-row justify-between items-center mb-2'>
82
+ <View className='flex-1'>
83
+ <Text className='text-sm font-medium text-gray-900 dark:text-gray-100'>
84
+ {label}
85
+ </Text>
86
+ {subtitle && (
87
+ <Text className='text-xs text-gray-500 dark:text-gray-400'>
88
+ {subtitle}
89
+ </Text>
90
+ )}
91
+ </View>
92
+ <View className='flex-row items-center gap-2'>
93
+ {showRemaining && (
94
+ <Text className='text-xs text-gray-500 dark:text-gray-400'>
95
+ {remaining.toLocaleString()} remaining
96
+ </Text>
97
+ )}
98
+ <Text className={cn('text-sm font-semibold', colors.text)}>
99
+ {current.toLocaleString()} / {limit.toLocaleString()}
100
+ </Text>
101
+ </View>
102
+ </View>
103
+
104
+ {/* Progress bar */}
105
+ <View className='h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden'>
106
+ <View
107
+ className={cn('h-full rounded-full', colors.bg)}
108
+ style={{ width: `${percentage}%` }}
109
+ />
110
+ </View>
111
+
112
+ {/* Percentage label */}
113
+ {showPercentage && (
114
+ <View className='flex-row justify-end mt-1'>
115
+ <Text className={cn('text-xs font-medium', colors.text)}>
116
+ {percentage.toFixed(1)}%
117
+ </Text>
118
+ </View>
119
+ )}
120
+ </View>
121
+ );
122
+
123
+ if (onPress) {
124
+ return (
125
+ <Pressable
126
+ onPress={onPress}
127
+ className='active:opacity-80'
128
+ accessibilityRole='button'
129
+ accessibilityLabel={`${label}: ${current} of ${limit} used`}
130
+ >
131
+ {content}
132
+ </Pressable>
133
+ );
134
+ }
135
+
136
+ return content;
137
+ }
138
+
139
+ /**
140
+ * UsageDashboard component displays multiple usage bars for different time periods
141
+ */
142
+ export function UsageDashboard({
143
+ bars,
144
+ title,
145
+ subtitle,
146
+ showPercentage = true,
147
+ showRemaining = true,
148
+ onBarPress,
149
+ className,
150
+ }: UsageDashboardProps) {
151
+ return (
152
+ <View
153
+ className={cn(
154
+ 'bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm',
155
+ className
156
+ )}
157
+ accessibilityRole='none'
158
+ accessibilityLabel={title || 'Usage Dashboard'}
159
+ >
160
+ {/* Header */}
161
+ {(title || subtitle) && (
162
+ <View className='mb-4'>
163
+ {title && (
164
+ <Text className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
165
+ {title}
166
+ </Text>
167
+ )}
168
+ {subtitle && (
169
+ <Text className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
170
+ {subtitle}
171
+ </Text>
172
+ )}
173
+ </View>
174
+ )}
175
+
176
+ {/* Usage bars */}
177
+ <View>
178
+ {bars.map((bar, index) => (
179
+ <UsageBar
180
+ key={`${bar.label}-${index}`}
181
+ config={bar}
182
+ showPercentage={showPercentage}
183
+ showRemaining={showRemaining}
184
+ onPress={onBarPress ? () => onBarPress(bar, index) : undefined}
185
+ />
186
+ ))}
187
+ </View>
188
+
189
+ {/* Empty state */}
190
+ {bars.length === 0 && (
191
+ <View className='py-8 items-center'>
192
+ <Text className='text-gray-500 dark:text-gray-400'>
193
+ No usage data available
194
+ </Text>
195
+ </View>
196
+ )}
197
+ </View>
198
+ );
199
+ }
200
+
201
+ export default UsageDashboard;
@@ -0,0 +1,365 @@
1
+ import { View, Text, Pressable, ScrollView } from 'react-native';
2
+ import { cn } from '@sudobility/components-rn';
3
+ import type {
4
+ UsageHistoryChartProps,
5
+ HistoryEntryData,
6
+ UsageBarColor,
7
+ } from './types';
8
+
9
+ /**
10
+ * Get color classes for chart elements
11
+ */
12
+ function getColorClasses(color: UsageBarColor): {
13
+ bg: string;
14
+ border: string;
15
+ text: string;
16
+ } {
17
+ const colorMap: Record<
18
+ UsageBarColor,
19
+ { bg: string; border: string; text: string }
20
+ > = {
21
+ green: {
22
+ bg: 'bg-green-500',
23
+ border: 'border-green-500',
24
+ text: 'text-green-600 dark:text-green-400',
25
+ },
26
+ yellow: {
27
+ bg: 'bg-yellow-500',
28
+ border: 'border-yellow-500',
29
+ text: 'text-yellow-600 dark:text-yellow-400',
30
+ },
31
+ orange: {
32
+ bg: 'bg-orange-500',
33
+ border: 'border-orange-500',
34
+ text: 'text-orange-600 dark:text-orange-400',
35
+ },
36
+ red: {
37
+ bg: 'bg-red-500',
38
+ border: 'border-red-500',
39
+ text: 'text-red-600 dark:text-red-400',
40
+ },
41
+ blue: {
42
+ bg: 'bg-blue-500',
43
+ border: 'border-blue-500',
44
+ text: 'text-blue-600 dark:text-blue-400',
45
+ },
46
+ gray: {
47
+ bg: 'bg-gray-500',
48
+ border: 'border-gray-500',
49
+ text: 'text-gray-600 dark:text-gray-400',
50
+ },
51
+ };
52
+ return colorMap[color];
53
+ }
54
+
55
+ /**
56
+ * Format timestamp for display
57
+ */
58
+ function formatTimestamp(timestamp: string | Date): string {
59
+ if (typeof timestamp === 'string') {
60
+ return timestamp;
61
+ }
62
+ return timestamp.toLocaleDateString(undefined, {
63
+ month: 'short',
64
+ day: 'numeric',
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Bar chart mode component
70
+ */
71
+ interface BarChartProps {
72
+ data: HistoryEntryData[];
73
+ height: number;
74
+ color: UsageBarColor;
75
+ showLimit: boolean;
76
+ showLabels: boolean;
77
+ onDataPointPress?: (entry: HistoryEntryData, index: number) => void;
78
+ }
79
+
80
+ function BarChart({
81
+ data,
82
+ height,
83
+ color,
84
+ showLimit,
85
+ showLabels,
86
+ onDataPointPress,
87
+ }: BarChartProps) {
88
+ const maxValue = Math.max(...data.map(d => Math.max(d.value, d.limit || 0)));
89
+ const colors = getColorClasses(color);
90
+
91
+ return (
92
+ <ScrollView horizontal showsHorizontalScrollIndicator={false}>
93
+ <View className='flex-row items-end' style={{ height }}>
94
+ {data.map((entry, index) => {
95
+ const barHeight =
96
+ maxValue > 0 ? (entry.value / maxValue) * (height - 40) : 0;
97
+ const limitHeight =
98
+ entry.limit && maxValue > 0
99
+ ? (entry.limit / maxValue) * (height - 40)
100
+ : 0;
101
+ const label = entry.label || formatTimestamp(entry.timestamp);
102
+
103
+ const barContent = (
104
+ <View className='items-center mx-1.5' key={index}>
105
+ {/* Value label */}
106
+ {showLabels && (
107
+ <Text className={cn('text-xs font-medium mb-1', colors.text)}>
108
+ {entry.value.toLocaleString()}
109
+ </Text>
110
+ )}
111
+
112
+ {/* Bar container */}
113
+ <View
114
+ className='relative items-center justify-end'
115
+ style={{ height: height - 40, width: 32 }}
116
+ >
117
+ {/* Limit indicator */}
118
+ {showLimit && entry.limit && (
119
+ <View
120
+ className='absolute left-0 right-0 border-t-2 border-dashed border-red-400'
121
+ style={{ bottom: limitHeight }}
122
+ />
123
+ )}
124
+
125
+ {/* Bar */}
126
+ <View
127
+ className={cn('w-6 rounded-t', colors.bg)}
128
+ style={{ height: Math.max(barHeight, 4) }}
129
+ />
130
+ </View>
131
+
132
+ {/* Timestamp label */}
133
+ <Text className='text-xs text-gray-500 dark:text-gray-400 mt-1 w-12 text-center'>
134
+ {label}
135
+ </Text>
136
+ </View>
137
+ );
138
+
139
+ if (onDataPointPress) {
140
+ return (
141
+ <Pressable
142
+ key={index}
143
+ onPress={() => onDataPointPress(entry, index)}
144
+ className='active:opacity-70'
145
+ accessibilityRole='button'
146
+ accessibilityLabel={label + ': ' + entry.value}
147
+ >
148
+ {barContent}
149
+ </Pressable>
150
+ );
151
+ }
152
+
153
+ return barContent;
154
+ })}
155
+ </View>
156
+ </ScrollView>
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Line chart mode component (simplified without SVG)
162
+ */
163
+ interface LineChartProps {
164
+ data: HistoryEntryData[];
165
+ height: number;
166
+ color: UsageBarColor;
167
+ showLimit: boolean;
168
+ showLabels: boolean;
169
+ onDataPointPress?: (entry: HistoryEntryData, index: number) => void;
170
+ }
171
+
172
+ function LineChart({
173
+ data,
174
+ height,
175
+ color,
176
+ showLimit,
177
+ showLabels,
178
+ onDataPointPress,
179
+ }: LineChartProps) {
180
+ const maxValue = Math.max(...data.map(d => Math.max(d.value, d.limit || 0)));
181
+ const colors = getColorClasses(color);
182
+
183
+ return (
184
+ <ScrollView horizontal showsHorizontalScrollIndicator={false}>
185
+ <View className='flex-row items-end' style={{ height }}>
186
+ {data.map((entry, index) => {
187
+ const dotY =
188
+ maxValue > 0
189
+ ? (1 - entry.value / maxValue) * (height - 50)
190
+ : height - 50;
191
+ const limitY =
192
+ entry.limit && maxValue > 0
193
+ ? (1 - entry.limit / maxValue) * (height - 50)
194
+ : null;
195
+ const label = entry.label || formatTimestamp(entry.timestamp);
196
+
197
+ const pointContent = (
198
+ <View className='items-center mx-3' key={index}>
199
+ {/* Chart area */}
200
+ <View
201
+ className='relative items-center'
202
+ style={{ height: height - 40, width: 40 }}
203
+ >
204
+ {/* Limit line */}
205
+ {showLimit && limitY !== null && (
206
+ <View
207
+ className='absolute left-0 right-0 h-0.5 bg-red-400/50'
208
+ style={{ top: limitY }}
209
+ />
210
+ )}
211
+
212
+ {/* Value label above dot */}
213
+ {showLabels && (
214
+ <View style={{ position: 'absolute', top: dotY - 20 }}>
215
+ <Text className={cn('text-xs font-medium', colors.text)}>
216
+ {entry.value.toLocaleString()}
217
+ </Text>
218
+ </View>
219
+ )}
220
+
221
+ {/* Data point dot */}
222
+ <View
223
+ className={cn('w-3 h-3 rounded-full absolute', colors.bg)}
224
+ style={{ top: dotY }}
225
+ />
226
+
227
+ {/* Vertical guide line */}
228
+ <View
229
+ className='w-px bg-gray-200 dark:bg-gray-700 absolute bottom-0'
230
+ style={{ height: height - 50 - dotY, top: dotY + 6 }}
231
+ />
232
+ </View>
233
+
234
+ {/* Timestamp label */}
235
+ <Text className='text-xs text-gray-500 dark:text-gray-400 mt-1 w-14 text-center'>
236
+ {label}
237
+ </Text>
238
+ </View>
239
+ );
240
+
241
+ if (onDataPointPress) {
242
+ return (
243
+ <Pressable
244
+ key={index}
245
+ onPress={() => onDataPointPress(entry, index)}
246
+ className='active:opacity-70'
247
+ accessibilityRole='button'
248
+ accessibilityLabel={label + ': ' + entry.value}
249
+ >
250
+ {pointContent}
251
+ </Pressable>
252
+ );
253
+ }
254
+
255
+ return pointContent;
256
+ })}
257
+ </View>
258
+ </ScrollView>
259
+ );
260
+ }
261
+
262
+ /**
263
+ * UsageHistoryChart component displays historical usage data
264
+ */
265
+ export function UsageHistoryChart({
266
+ data,
267
+ title,
268
+ height = 200,
269
+ color = 'blue',
270
+ showLimit = true,
271
+ showLabels = true,
272
+ mode = 'bar',
273
+ onDataPointPress,
274
+ className,
275
+ }: UsageHistoryChartProps) {
276
+ // Calculate summary stats
277
+ const totalUsage = data.reduce((sum, entry) => sum + entry.value, 0);
278
+ const avgUsage = data.length > 0 ? Math.round(totalUsage / data.length) : 0;
279
+ const maxUsage = data.length > 0 ? Math.max(...data.map(d => d.value)) : 0;
280
+
281
+ return (
282
+ <View
283
+ className={cn(
284
+ 'bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm',
285
+ className
286
+ )}
287
+ accessibilityRole='none'
288
+ accessibilityLabel={title || 'Usage History Chart'}
289
+ >
290
+ {/* Header */}
291
+ {title && (
292
+ <Text className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
293
+ {title}
294
+ </Text>
295
+ )}
296
+
297
+ {/* Stats row */}
298
+ <View className='flex-row justify-between mb-4 pb-3 border-b border-gray-100 dark:border-gray-700'>
299
+ <View className='items-center'>
300
+ <Text className='text-xs text-gray-500 dark:text-gray-400'>
301
+ Total
302
+ </Text>
303
+ <Text className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
304
+ {totalUsage.toLocaleString()}
305
+ </Text>
306
+ </View>
307
+ <View className='items-center'>
308
+ <Text className='text-xs text-gray-500 dark:text-gray-400'>
309
+ Average
310
+ </Text>
311
+ <Text className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
312
+ {avgUsage.toLocaleString()}
313
+ </Text>
314
+ </View>
315
+ <View className='items-center'>
316
+ <Text className='text-xs text-gray-500 dark:text-gray-400'>Peak</Text>
317
+ <Text className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
318
+ {maxUsage.toLocaleString()}
319
+ </Text>
320
+ </View>
321
+ </View>
322
+
323
+ {/* Chart */}
324
+ {data.length > 0 ? (
325
+ mode === 'bar' ? (
326
+ <BarChart
327
+ data={data}
328
+ height={height}
329
+ color={color}
330
+ showLimit={showLimit}
331
+ showLabels={showLabels}
332
+ onDataPointPress={onDataPointPress}
333
+ />
334
+ ) : (
335
+ <LineChart
336
+ data={data}
337
+ height={height}
338
+ color={color}
339
+ showLimit={showLimit}
340
+ showLabels={showLabels}
341
+ onDataPointPress={onDataPointPress}
342
+ />
343
+ )
344
+ ) : (
345
+ <View className='items-center justify-center' style={{ height }}>
346
+ <Text className='text-gray-500 dark:text-gray-400'>
347
+ No history data available
348
+ </Text>
349
+ </View>
350
+ )}
351
+
352
+ {/* Legend */}
353
+ {showLimit && data.some(d => d.limit) && (
354
+ <View className='flex-row items-center justify-center mt-3 pt-2 border-t border-gray-100 dark:border-gray-700'>
355
+ <View className='w-4 h-0.5 bg-red-400 mr-2' />
356
+ <Text className='text-xs text-gray-500 dark:text-gray-400'>
357
+ Limit
358
+ </Text>
359
+ </View>
360
+ )}
361
+ </View>
362
+ );
363
+ }
364
+
365
+ export default UsageHistoryChart;
@@ -0,0 +1,143 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react-native';
3
+ import { TierComparisonTable } from '../TierComparisonTable';
4
+ import { TierDisplayData } from '../types';
5
+
6
+ jest.mock('@sudobility/components-rn', () => ({
7
+ cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
8
+ }));
9
+
10
+ const sampleTiers: TierDisplayData[] = [
11
+ {
12
+ name: 'Free',
13
+ hourlyLimit: 100,
14
+ dailyLimit: 1000,
15
+ monthlyLimit: 10000,
16
+ price: '$0/mo',
17
+ isCurrent: true,
18
+ },
19
+ {
20
+ name: 'Pro',
21
+ hourlyLimit: 1000,
22
+ dailyLimit: 10000,
23
+ monthlyLimit: 100000,
24
+ price: '$29/mo',
25
+ isRecommended: true,
26
+ description: 'Best for growing teams',
27
+ features: ['Priority support', 'Advanced analytics'],
28
+ },
29
+ {
30
+ name: 'Enterprise',
31
+ hourlyLimit: 10000,
32
+ dailyLimit: 100000,
33
+ monthlyLimit: 1000000,
34
+ price: '$99/mo',
35
+ },
36
+ ];
37
+
38
+ describe('TierComparisonTable', () => {
39
+ it('renders tier names', () => {
40
+ render(<TierComparisonTable tiers={sampleTiers} />);
41
+ expect(screen.getByText('Free')).toBeTruthy();
42
+ expect(screen.getByText('Pro')).toBeTruthy();
43
+ expect(screen.getByText('Enterprise')).toBeTruthy();
44
+ });
45
+
46
+ it('renders title when provided', () => {
47
+ render(<TierComparisonTable tiers={sampleTiers} title="Choose a Plan" />);
48
+ expect(screen.getByText('Choose a Plan')).toBeTruthy();
49
+ });
50
+
51
+ it('shows Current badge on current tier', () => {
52
+ render(<TierComparisonTable tiers={sampleTiers} />);
53
+ expect(screen.getByText('Current')).toBeTruthy();
54
+ });
55
+
56
+ it('shows Recommended badge on recommended tier', () => {
57
+ render(<TierComparisonTable tiers={sampleTiers} />);
58
+ expect(screen.getByText('Recommended')).toBeTruthy();
59
+ });
60
+
61
+ it('displays formatted limits with K/M suffixes', () => {
62
+ render(<TierComparisonTable tiers={sampleTiers} />);
63
+ // Free tier: 100, 1K, 10K
64
+ expect(screen.getByText('100')).toBeTruthy();
65
+ expect(screen.getAllByText('1K').length).toBeGreaterThan(0);
66
+ expect(screen.getAllByText('10K').length).toBeGreaterThan(0);
67
+ // Enterprise tier: 1M
68
+ expect(screen.getByText('1M')).toBeTruthy();
69
+ });
70
+
71
+ it('displays prices when showPrice is true', () => {
72
+ render(<TierComparisonTable tiers={sampleTiers} showPrice />);
73
+ expect(screen.getByText('$0/mo')).toBeTruthy();
74
+ expect(screen.getByText('$29/mo')).toBeTruthy();
75
+ expect(screen.getByText('$99/mo')).toBeTruthy();
76
+ });
77
+
78
+ it('hides prices when showPrice is false', () => {
79
+ render(<TierComparisonTable tiers={sampleTiers} showPrice={false} />);
80
+ expect(screen.queryByText('$0/mo')).toBeNull();
81
+ expect(screen.queryByText('$29/mo')).toBeNull();
82
+ });
83
+
84
+ it('renders tier description', () => {
85
+ render(<TierComparisonTable tiers={sampleTiers} />);
86
+ expect(screen.getByText('Best for growing teams')).toBeTruthy();
87
+ });
88
+
89
+ it('renders tier features', () => {
90
+ render(<TierComparisonTable tiers={sampleTiers} />);
91
+ expect(screen.getByText('Priority support')).toBeTruthy();
92
+ expect(screen.getByText('Advanced analytics')).toBeTruthy();
93
+ });
94
+
95
+ it('calls onTierSelect when a non-current tier is pressed', () => {
96
+ const onTierSelect = jest.fn();
97
+ render(<TierComparisonTable tiers={sampleTiers} onTierSelect={onTierSelect} />);
98
+
99
+ // Non-current tiers should have select buttons
100
+ const buttons = screen.getAllByRole('button');
101
+ fireEvent.press(buttons[0]);
102
+
103
+ expect(onTierSelect).toHaveBeenCalledTimes(1);
104
+ });
105
+
106
+ it('does not render select button for current tier', () => {
107
+ const onTierSelect = jest.fn();
108
+ render(<TierComparisonTable tiers={sampleTiers} onTierSelect={onTierSelect} />);
109
+
110
+ // Current tier (Free) should not have a select button.
111
+ // Non-current tiers (Pro, Enterprise) should have buttons.
112
+ const buttons = screen.getAllByRole('button');
113
+ expect(buttons).toHaveLength(2);
114
+ });
115
+
116
+ it('shows Upgrade Now text for recommended tier', () => {
117
+ const onTierSelect = jest.fn();
118
+ render(<TierComparisonTable tiers={sampleTiers} onTierSelect={onTierSelect} />);
119
+ expect(screen.getByText('Upgrade Now')).toBeTruthy();
120
+ });
121
+
122
+ it('shows Select Plan text for non-recommended, non-current tier', () => {
123
+ const onTierSelect = jest.fn();
124
+ render(<TierComparisonTable tiers={sampleTiers} onTierSelect={onTierSelect} />);
125
+ expect(screen.getByText('Select Plan')).toBeTruthy();
126
+ });
127
+
128
+ it('renders empty state when tiers array is empty', () => {
129
+ render(<TierComparisonTable tiers={[]} />);
130
+ expect(screen.getByText('No tiers available')).toBeTruthy();
131
+ });
132
+
133
+ it('passes selected tier data to onTierSelect', () => {
134
+ const onTierSelect = jest.fn();
135
+ render(<TierComparisonTable tiers={sampleTiers} onTierSelect={onTierSelect} />);
136
+
137
+ // Press the Pro tier (recommended) button -- first button
138
+ const buttons = screen.getAllByRole('button');
139
+ fireEvent.press(buttons[0]);
140
+
141
+ expect(onTierSelect).toHaveBeenCalledWith(sampleTiers[1]);
142
+ });
143
+ });