@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.
- package/dist/TierComparisonTable.d.ts +7 -0
- package/dist/TierComparisonTable.d.ts.map +1 -0
- package/dist/UsageDashboard.d.ts +7 -0
- package/dist/UsageDashboard.d.ts.map +1 -0
- package/dist/UsageHistoryChart.d.ts +7 -0
- package/dist/UsageHistoryChart.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1423 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1423 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/TierComparisonTable.tsx +220 -0
- package/src/UsageDashboard.tsx +201 -0
- package/src/UsageHistoryChart.tsx +365 -0
- package/src/__tests__/TierComparisonTable.test.tsx +143 -0
- package/src/__tests__/UsageDashboard.test.tsx +116 -0
- package/src/index.ts +20 -0
- package/src/nativewind.d.ts +27 -0
- package/src/types.ts +128 -0
|
@@ -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
|
+
});
|