@bettoredge/calcutta 0.3.1 → 0.4.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.
@@ -11,7 +11,7 @@ export interface CalcuttaBidInputProps {
11
11
  auction_type: 'sealed_bid' | 'live';
12
12
  escrow_balance: number;
13
13
  existing_bid_amount?: number;
14
- onSubmit: (amount: number) => void;
14
+ onSubmit: (amount: number) => void | Promise<void>;
15
15
  loading: boolean;
16
16
  disabled?: boolean;
17
17
  }
@@ -33,136 +33,129 @@ export const CalcuttaBidInput: React.FC<CalcuttaBidInputProps> = ({
33
33
  ? Math.max(Number(min_bid), Number(current_bid) + Number(bid_increment))
34
34
  : Number(min_bid);
35
35
 
36
- const [amount, setAmount] = useState<string>(
37
- existing_bid_amount ? existing_bid_amount.toString() : minimumAllowed.toString()
38
- );
39
- const [error, setError] = useState<string>('');
36
+ const step = Number(bid_increment) > 0 ? Number(bid_increment) : 1;
37
+ const available = escrow_balance;
38
+ const isHighestBidder = existing_bid_amount != null && existing_bid_amount >= Number(current_bid) && Number(current_bid) > 0;
39
+ const [raise, setRaise] = useState(0);
40
+ const [error, setError] = useState('');
41
+ const [submitting, setSubmitting] = useState(false);
40
42
 
41
- useEffect(() => {
42
- if (!existing_bid_amount) {
43
- setAmount(minimumAllowed.toString());
44
- }
45
- }, [minimumAllowed, existing_bid_amount]);
43
+ // Total bid = minimum + raise
44
+ const totalBid = minimumAllowed + raise;
45
+ const canAfford = totalBid <= available;
46
46
 
47
- const numericAmount = parseFloat(amount) || 0;
47
+ // Reset raise when minimum changes (new bid came in)
48
+ useEffect(() => {
49
+ setRaise(0);
50
+ }, [minimumAllowed]);
48
51
 
49
- const validate = (): boolean => {
50
- if (isNaN(parseFloat(amount)) || numericAmount <= 0) {
51
- setError('Enter a valid bid amount');
52
- return false;
53
- }
54
- if (numericAmount < minimumAllowed) {
55
- setError(`Minimum bid is ${formatCurrency(minimumAllowed)}`);
56
- return false;
57
- }
58
- if (auction_type === 'live' && Number(bid_increment) > 0 && (numericAmount - minimumAllowed) % Number(bid_increment) !== 0) {
59
- setError(`Bid must be in increments of ${formatCurrency(Number(bid_increment))}`);
60
- return false;
61
- }
62
- const available = escrow_balance + (existing_bid_amount || 0);
63
- if (numericAmount > available) {
52
+ const handleSubmit = async () => {
53
+ if (!canAfford) {
64
54
  setError(`Insufficient escrow. Available: ${formatCurrency(available)}`);
65
- return false;
55
+ return;
66
56
  }
57
+ setSubmitting(true);
67
58
  setError('');
68
- return true;
59
+ try {
60
+ await onSubmit(totalBid);
61
+ setRaise(0);
62
+ } catch (e: any) {
63
+ setError(e?.message || 'Failed to place bid');
64
+ }
65
+ setSubmitting(false);
69
66
  };
70
67
 
71
- const handleSubmit = () => {
72
- if (validate()) {
73
- onSubmit(numericAmount);
68
+ const bumpUp = () => {
69
+ const next = raise + step;
70
+ if (minimumAllowed + next <= available) {
71
+ setRaise(next);
72
+ setError('');
74
73
  }
75
74
  };
76
75
 
77
- const adjustAmount = (direction: 'up' | 'down') => {
78
- const step = Number(bid_increment) > 0 ? Number(bid_increment) : 1;
79
- const current = numericAmount;
80
- const newAmount = direction === 'up' ? current + step : Math.max(minimumAllowed, current - step);
81
- setAmount(newAmount.toString());
76
+ const bumpDown = () => {
77
+ const next = raise - step;
78
+ setRaise(Math.max(0, next));
82
79
  setError('');
83
80
  };
84
81
 
85
82
  return (
86
83
  <View variant="transparent" style={styles.container}>
87
- {/* Amount input row */}
88
- <View variant="transparent" style={styles.inputRow}>
89
- <TouchableOpacity
90
- style={[styles.adjustButton, { backgroundColor: theme.colors.surface.base, borderColor: theme.colors.border.subtle }]}
91
- onPress={() => adjustAmount('down')}
92
- disabled={numericAmount <= minimumAllowed}
93
- >
94
- <Ionicons
95
- name="remove"
96
- size={18}
97
- color={numericAmount <= minimumAllowed ? theme.colors.text.tertiary : theme.colors.text.primary}
98
- />
99
- </TouchableOpacity>
100
-
101
- <TextInput
102
- style={[
103
- styles.input,
104
- {
105
- color: theme.colors.text.primary,
106
- backgroundColor: theme.colors.surface.input,
107
- borderColor: error ? theme.colors.status.error : theme.colors.border.subtle,
108
- },
109
- ]}
110
- value={amount}
111
- onChangeText={(text) => {
112
- setAmount(text);
113
- setError('');
114
- }}
115
- keyboardType="decimal-pad"
116
- placeholder={formatCurrency(minimumAllowed)}
117
- placeholderTextColor={theme.colors.text.tertiary}
118
- />
119
-
120
- <TouchableOpacity
121
- style={[styles.adjustButton, { backgroundColor: theme.colors.surface.base, borderColor: theme.colors.border.subtle }]}
122
- onPress={() => adjustAmount('up')}
123
- >
124
- <Ionicons name="add" size={18} color={theme.colors.text.primary} />
125
- </TouchableOpacity>
126
- </View>
127
-
128
- {/* Error message */}
129
- {error !== '' && (
130
- <Text variant="caption" style={[styles.errorText, { color: theme.colors.status.error }]}>
131
- {error}
132
- </Text>
133
- )}
134
-
135
- {/* Min bid info */}
136
- <Text variant="caption" color="tertiary" style={styles.minLabel}>
137
- Min: {formatCurrency(minimumAllowed)}
138
- {Number(bid_increment) > 0 && ` \u00B7 Increment: ${formatCurrency(Number(bid_increment))}`}
139
- </Text>
140
- <Text variant="caption" color="tertiary" style={styles.minLabel}>
141
- Available: {formatCurrency(escrow_balance + (existing_bid_amount || 0))}
142
- </Text>
143
-
144
- {/* Submit button */}
145
84
  {disabled ? (
146
- <View variant="transparent" style={[styles.submitButton, { backgroundColor: theme.colors.surface.base }]}>
147
- <Text variant="body" bold style={{ color: theme.colors.text.tertiary }}>
148
- Paused
149
- </Text>
85
+ <View variant="transparent" style={[styles.bidButton, { backgroundColor: theme.colors.surface.base }]}>
86
+ <Text variant="body" bold style={{ color: theme.colors.text.tertiary }}>Paused</Text>
150
87
  </View>
151
- ) : (
152
- <TouchableOpacity
153
- style={[styles.submitButton, { backgroundColor: theme.colors.primary.default }]}
154
- onPress={handleSubmit}
155
- disabled={loading}
156
- activeOpacity={0.7}
157
- >
158
- {loading ? (
159
- <ActivityIndicator size="small" color="#FFFFFF" />
160
- ) : (
161
- <Text variant="body" bold style={styles.submitText}>
162
- {existing_bid_amount ? 'Update Bid' : 'Place Bid'}
88
+ ) : isHighestBidder ? (
89
+ <View variant="transparent" style={[styles.highestBidder, { backgroundColor: '#10B98115' }]}>
90
+ <Ionicons name="checkmark-circle" size={20} color="#10B981" />
91
+ <View variant="transparent" style={{ marginLeft: 10, flex: 1 }}>
92
+ <Text variant="body" bold style={{ color: '#10B981' }}>You're the highest bidder</Text>
93
+ <Text variant="caption" color="secondary">
94
+ Your bid: {formatCurrency(existing_bid_amount)} · {formatCurrency(available)} available to raise
163
95
  </Text>
164
- )}
165
- </TouchableOpacity>
96
+ </View>
97
+ </View>
98
+ ) : (
99
+ <>
100
+ {/* Raise adjuster row */}
101
+ <View variant="transparent" style={styles.raiseRow}>
102
+ <TouchableOpacity
103
+ style={[styles.adjustBtn, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle, opacity: raise <= 0 ? 0.4 : 1 }]}
104
+ onPress={bumpDown}
105
+ disabled={raise <= 0}
106
+ activeOpacity={0.7}
107
+ >
108
+ <Ionicons name="remove" size={20} color={theme.colors.text.primary} />
109
+ </TouchableOpacity>
110
+
111
+ <View variant="transparent" style={styles.raiseDisplay}>
112
+ {raise > 0 ? (
113
+ <Text variant="caption" color="secondary">+{formatCurrency(raise)} more</Text>
114
+ ) : (
115
+ <Text variant="caption" color="tertiary">Minimum bid</Text>
116
+ )}
117
+ <Text variant="h3" bold>{formatCurrency(totalBid)}</Text>
118
+ </View>
119
+
120
+ <TouchableOpacity
121
+ style={[styles.adjustBtn, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle, opacity: (minimumAllowed + raise + step > available) ? 0.4 : 1 }]}
122
+ onPress={bumpUp}
123
+ disabled={minimumAllowed + raise + step > available}
124
+ activeOpacity={0.7}
125
+ >
126
+ <Ionicons name="add" size={20} color={theme.colors.text.primary} />
127
+ </TouchableOpacity>
128
+ </View>
129
+
130
+ {/* Bid button */}
131
+ <TouchableOpacity
132
+ style={[styles.bidButton, { backgroundColor: canAfford ? theme.colors.primary.default : theme.colors.surface.elevated }]}
133
+ onPress={handleSubmit}
134
+ disabled={submitting || !canAfford}
135
+ activeOpacity={0.7}
136
+ >
137
+ {submitting ? (
138
+ <ActivityIndicator size="small" color="#FFFFFF" />
139
+ ) : (
140
+ <Text variant="body" bold style={{ color: canAfford ? '#FFFFFF' : theme.colors.text.tertiary }}>
141
+ {existing_bid_amount ? 'Raise to' : 'Place Bid'} {formatCurrency(totalBid)}
142
+ </Text>
143
+ )}
144
+ </TouchableOpacity>
145
+
146
+ {/* Available */}
147
+ <Text variant="caption" color="tertiary" style={{ marginTop: 4, textAlign: 'center' }}>
148
+ {formatCurrency(available)} available
149
+ </Text>
150
+ </>
151
+ )}
152
+
153
+ {/* Error */}
154
+ {error !== '' && (
155
+ <View variant="transparent" style={[styles.errorRow, { backgroundColor: theme.colors.status.error + '10' }]}>
156
+ <Ionicons name="alert-circle" size={14} color={theme.colors.status.error} />
157
+ <Text variant="caption" style={{ color: theme.colors.status.error, marginLeft: 6, flex: 1 }}>{error}</Text>
158
+ </View>
166
159
  )}
167
160
  </View>
168
161
  );
@@ -170,45 +163,43 @@ export const CalcuttaBidInput: React.FC<CalcuttaBidInputProps> = ({
170
163
 
171
164
  const styles = StyleSheet.create({
172
165
  container: {
173
- paddingTop: 10,
166
+ paddingTop: 8,
174
167
  },
175
- inputRow: {
168
+ raiseRow: {
176
169
  flexDirection: 'row',
177
170
  alignItems: 'center',
171
+ marginBottom: 8,
178
172
  },
179
- adjustButton: {
180
- width: 36,
181
- height: 36,
182
- borderRadius: 8,
173
+ adjustBtn: {
174
+ width: 44,
175
+ height: 44,
176
+ borderRadius: 10,
183
177
  borderWidth: 1,
184
178
  alignItems: 'center',
185
179
  justifyContent: 'center',
186
180
  },
187
- input: {
181
+ raiseDisplay: {
188
182
  flex: 1,
189
- height: 36,
190
- borderRadius: 8,
191
- borderWidth: 1,
192
- marginHorizontal: 8,
193
- paddingHorizontal: 12,
194
- fontSize: 16,
195
- textAlign: 'center',
196
- },
197
- errorText: {
198
- marginTop: 4,
199
- fontSize: 11,
200
- },
201
- minLabel: {
202
- marginTop: 4,
183
+ alignItems: 'center',
184
+ justifyContent: 'center',
203
185
  },
204
- submitButton: {
205
- marginTop: 10,
206
- paddingVertical: 10,
207
- borderRadius: 8,
186
+ bidButton: {
187
+ height: 46,
188
+ borderRadius: 10,
208
189
  alignItems: 'center',
209
190
  justifyContent: 'center',
210
191
  },
211
- submitText: {
212
- color: '#FFFFFF',
192
+ highestBidder: {
193
+ flexDirection: 'row',
194
+ alignItems: 'center',
195
+ padding: 14,
196
+ borderRadius: 10,
197
+ },
198
+ errorRow: {
199
+ flexDirection: 'row',
200
+ alignItems: 'center',
201
+ marginTop: 6,
202
+ padding: 8,
203
+ borderRadius: 8,
213
204
  },
214
205
  });
@@ -0,0 +1,183 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { StyleSheet, Animated } from 'react-native';
3
+ import { View, Text, useTheme } from '@bettoredge/styles';
4
+ import { Ionicons } from '@expo/vector-icons';
5
+
6
+ export interface CalcuttaCountdownProps {
7
+ targetDate: string;
8
+ label?: string;
9
+ size?: 'compact' | 'normal';
10
+ }
11
+
12
+ type UrgencyTier = 'calm' | 'upcoming' | 'soon' | 'urgent' | 'critical' | 'imminent' | 'past';
13
+
14
+ const getTier = (ms: number): UrgencyTier => {
15
+ if (ms <= 0) return 'past';
16
+ if (ms < 60_000) return 'imminent'; // < 1 min
17
+ if (ms < 600_000) return 'critical'; // < 10 min
18
+ if (ms < 3_600_000) return 'urgent'; // < 1 hour
19
+ if (ms < 86_400_000) return 'soon'; // < 24 hours
20
+ if (ms < 604_800_000) return 'upcoming'; // < 7 days
21
+ return 'calm';
22
+ };
23
+
24
+ const formatRemaining = (ms: number, tier: UrgencyTier): string => {
25
+ if (tier === 'past') return 'Now';
26
+
27
+ const totalSeconds = Math.floor(ms / 1000);
28
+ const days = Math.floor(totalSeconds / 86400);
29
+ const hours = Math.floor((totalSeconds % 86400) / 3600);
30
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
31
+ const seconds = totalSeconds % 60;
32
+
33
+ switch (tier) {
34
+ case 'calm': {
35
+ const d = new Date(Date.now() + ms);
36
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
37
+ }
38
+ case 'upcoming':
39
+ return `${days}d ${hours}h`;
40
+ case 'soon':
41
+ return `${hours}h ${minutes}m`;
42
+ case 'urgent':
43
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
44
+ case 'critical':
45
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
46
+ case 'imminent':
47
+ return `0:${String(seconds).padStart(2, '0')}`;
48
+ default:
49
+ return '';
50
+ }
51
+ };
52
+
53
+ export const CalcuttaCountdown: React.FC<CalcuttaCountdownProps> = ({
54
+ targetDate,
55
+ label,
56
+ size = 'normal',
57
+ }) => {
58
+ const { theme } = useTheme();
59
+ const [remaining, setRemaining] = useState(() => new Date(targetDate).getTime() - Date.now());
60
+ const pulseAnim = useRef(new Animated.Value(1)).current;
61
+ const tier = getTier(remaining);
62
+
63
+ // Live tick for urgent tiers
64
+ useEffect(() => {
65
+ const needsTick = ['urgent', 'critical', 'imminent'].includes(tier);
66
+ if (!needsTick) {
67
+ // Update every minute for less urgent tiers
68
+ const interval = setInterval(() => {
69
+ setRemaining(new Date(targetDate).getTime() - Date.now());
70
+ }, 60_000);
71
+ return () => clearInterval(interval);
72
+ }
73
+ const interval = setInterval(() => {
74
+ setRemaining(new Date(targetDate).getTime() - Date.now());
75
+ }, 1000);
76
+ return () => clearInterval(interval);
77
+ }, [targetDate, tier]);
78
+
79
+ // Pulse animation for critical/imminent
80
+ useEffect(() => {
81
+ if (tier === 'critical' || tier === 'imminent') {
82
+ const loop = Animated.loop(
83
+ Animated.sequence([
84
+ Animated.timing(pulseAnim, { toValue: 0.6, duration: 600, useNativeDriver: true }),
85
+ Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
86
+ ])
87
+ );
88
+ loop.start();
89
+ return () => loop.stop();
90
+ } else {
91
+ pulseAnim.setValue(1);
92
+ }
93
+ }, [tier]);
94
+
95
+ const getTierColor = () => {
96
+ switch (tier) {
97
+ case 'calm':
98
+ case 'upcoming':
99
+ return theme.colors.text.secondary;
100
+ case 'soon':
101
+ return theme.colors.primary.default;
102
+ case 'urgent':
103
+ return '#D97706'; // amber
104
+ case 'critical':
105
+ case 'imminent':
106
+ return theme.colors.status.error;
107
+ case 'past':
108
+ return theme.colors.text.tertiary;
109
+ }
110
+ };
111
+
112
+ const getTierBg = () => {
113
+ switch (tier) {
114
+ case 'critical':
115
+ case 'imminent':
116
+ return theme.colors.status.error + '15';
117
+ case 'urgent':
118
+ return '#D9770615';
119
+ default:
120
+ return 'transparent';
121
+ }
122
+ };
123
+
124
+ const isCompact = size === 'compact';
125
+ const timeColor = getTierColor();
126
+ const formatted = formatRemaining(remaining, tier);
127
+
128
+ if (tier === 'past') return null;
129
+
130
+ return (
131
+ <Animated.View style={[
132
+ styles.container,
133
+ isCompact && styles.containerCompact,
134
+ { backgroundColor: getTierBg(), opacity: tier === 'critical' || tier === 'imminent' ? pulseAnim : 1 },
135
+ ]}>
136
+ {label && (
137
+ <Text variant="caption" color="tertiary" style={isCompact ? styles.labelCompact : styles.label}>
138
+ {label}
139
+ </Text>
140
+ )}
141
+ <View variant="transparent" style={styles.timeRow}>
142
+ <Ionicons
143
+ name="time-outline"
144
+ size={isCompact ? 14 : 16}
145
+ color={timeColor}
146
+ style={{ marginRight: 4 }}
147
+ />
148
+ <Text
149
+ variant={isCompact ? 'caption' : 'body'}
150
+ bold={tier !== 'calm' && tier !== 'upcoming'}
151
+ style={{ color: timeColor }}
152
+ >
153
+ {tier === 'calm' || tier === 'upcoming' ? formatted : formatted}
154
+ </Text>
155
+ </View>
156
+ </Animated.View>
157
+ );
158
+ };
159
+
160
+ const styles = StyleSheet.create({
161
+ container: {
162
+ borderRadius: 8,
163
+ paddingHorizontal: 10,
164
+ paddingVertical: 6,
165
+ alignSelf: 'flex-start',
166
+ },
167
+ containerCompact: {
168
+ paddingHorizontal: 8,
169
+ paddingVertical: 4,
170
+ flexDirection: 'row',
171
+ alignItems: 'center',
172
+ },
173
+ label: {
174
+ marginBottom: 2,
175
+ },
176
+ labelCompact: {
177
+ marginRight: 6,
178
+ },
179
+ timeRow: {
180
+ flexDirection: 'row',
181
+ alignItems: 'center',
182
+ },
183
+ });