@bettoredge/calcutta 0.3.1 → 0.4.0

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.
@@ -1,4 +1,4 @@
1
- import React, { useState, useMemo, useCallback } from 'react';
1
+ import React, { useState, useMemo, useCallback, useEffect } from 'react';
2
2
  import { StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Image, FlatList, TextInput, Platform } from 'react-native';
3
3
  import { View, Text, useTheme } from '@bettoredge/styles';
4
4
  import { Ionicons } from '@expo/vector-icons';
@@ -7,16 +7,30 @@ import { useCalcuttaCompetition } from '../hooks/useCalcuttaCompetition';
7
7
  import { useCalcuttaPlayers } from '../hooks/useCalcuttaPlayers';
8
8
  import { useCalcuttaItemImages } from '../hooks/useCalcuttaItemImages';
9
9
  import { formatCurrency, getStatusLabel, resolveItemImageUrl } from '../helpers/formatting';
10
- import { startSweepstakesCompetition } from '@bettoredge/api';
10
+ import { deriveLifecycleState, canManageEscrow } from '../helpers/lifecycleState';
11
+ import { useCalcuttaEscrow } from '../hooks/useCalcuttaEscrow';
12
+ import { startSweepstakesCompetition, startCalcuttaAuction } from '@bettoredge/api';
13
+ import { CalcuttaActionCard } from './CalcuttaActionCard';
14
+ import { CalcuttaCountdown } from './CalcuttaCountdown';
15
+ import { AuctionInfoChips } from './AuctionInfoChips';
16
+ import { EscrowWidget } from './EscrowWidget';
17
+ import { AuctionCountdownOverlay } from './AuctionCountdownOverlay';
18
+ import { useCalcuttaSocket } from '../hooks/useCalcuttaSocket';
11
19
 
12
20
  export interface CalcuttaDetailProps {
13
21
  calcutta_competition_id: string;
14
22
  player_id?: string;
15
- onClose: () => void;
23
+ access_token?: string;
24
+ device_id?: string;
25
+ player_username?: string;
26
+ player_profile_pic?: string;
27
+ onClose?: () => void;
16
28
  onJoin?: () => Promise<void>;
17
29
  onLeave?: () => Promise<void>;
18
30
  onShare?: () => void;
19
31
  onManage?: () => void;
32
+ player_balance?: number;
33
+ onDepositFunds?: (amount: number) => void;
20
34
  }
21
35
 
22
36
  const getStatusConfig = (status: string) => {
@@ -38,8 +52,35 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
38
52
  onLeave,
39
53
  onShare,
40
54
  onManage,
55
+ player_balance,
56
+ onDepositFunds,
57
+ access_token,
58
+ device_id,
59
+ player_username,
60
+ player_profile_pic,
41
61
  }) => {
42
62
  const { theme } = useTheme();
63
+
64
+ // Countdown overlay state
65
+ const [showCountdown, setShowCountdown] = useState(false);
66
+ const [countdownItem, setCountdownItem] = useState<any>(undefined);
67
+
68
+ // Socket for presence + auction start detection
69
+ const handleItemActive = useCallback((data: any) => {
70
+ // Auction just started — show countdown for ALL connected clients
71
+ setCountdownItem(data.item);
72
+ setShowCountdown(true);
73
+ }, []);
74
+
75
+ const { connected: socketConnected, presence, socketState } = useCalcuttaSocket(
76
+ calcutta_competition_id,
77
+ access_token,
78
+ device_id,
79
+ { username: player_username, profile_pic: player_profile_pic },
80
+ { onItemActive: handleItemActive },
81
+ );
82
+ const onlinePlayers = presence.players;
83
+ const onlinePlayerIds = useMemo(() => new Set(onlinePlayers.map(p => p.player_id)), [onlinePlayers]);
43
84
  const {
44
85
  loading,
45
86
  competition,
@@ -52,6 +93,9 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
52
93
 
53
94
  const participantIds = useMemo(() => participants.map(p => p.player_id), [participants]);
54
95
  const { players: enrichedPlayers } = useCalcuttaPlayers(participantIds);
96
+ const { escrow, fetchEscrow } = useCalcuttaEscrow(calcutta_competition_id);
97
+ const [escrowExpanded, setEscrowExpanded] = useState(false);
98
+
55
99
  const { images: itemImages } = useCalcuttaItemImages(items);
56
100
 
57
101
  const [joining, setJoining] = useState(false);
@@ -101,6 +145,8 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
101
145
  const totalPot = Number(competition.total_pot) || 0;
102
146
  const canLeave = hasJoined && !isSweepstakes && ['pending', 'scheduled'].includes(competition.status);
103
147
  const heroImage = competition.image?.url;
148
+ const lifecycleState = deriveLifecycleState(competition.status, competition.auction_status, competition.auction_type);
149
+ const showEscrow = canManageEscrow(lifecycleState, competition.auction_type) && hasJoined;
104
150
 
105
151
  // Sweepstakes: find the player's assigned item
106
152
  const myParticipant = participants.find(p => p.player_id == player_id);
@@ -114,13 +160,15 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
114
160
  const sections = [
115
161
  { key: 'hero' },
116
162
  { key: 'info' },
163
+ { key: 'action-card' },
164
+ ...(showEscrow ? [{ key: 'escrow' }] : []),
117
165
  { key: 'stats' },
118
- { key: 'cta' },
119
166
  { key: 'payouts' },
120
167
  { key: 'items-header' },
121
168
  { key: 'items' },
122
169
  { key: 'participants-header' },
123
170
  { key: 'participants' },
171
+ ...(canLeave ? [{ key: 'leave' }] : []),
124
172
  ];
125
173
 
126
174
  const renderSection = ({ item }: { item: { key: string } }) => {
@@ -145,6 +193,17 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
145
193
  <Text variant="caption" color="tertiary" style={{ marginLeft: 8 }}>
146
194
  {isSweepstakes ? 'Sweepstakes' : competition.auction_type === 'live' ? 'Live Auction' : 'Sealed Bid'}
147
195
  </Text>
196
+ <View variant="transparent" style={{ flex: 1 }} />
197
+ {isAdmin && onManage && (
198
+ <TouchableOpacity
199
+ onPress={onManage}
200
+ style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: theme.colors.surface.elevated, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 8, borderWidth: 1, borderColor: theme.colors.border.subtle }}
201
+ activeOpacity={0.7}
202
+ >
203
+ <Ionicons name="settings-outline" size={14} color={theme.colors.text.secondary} />
204
+ <Text variant="caption" color="secondary" style={{ marginLeft: 4 }}>Manage</Text>
205
+ </TouchableOpacity>
206
+ )}
148
207
  </View>
149
208
  <Text variant="h3" bold>{competition.competition_name}</Text>
150
209
  {competition.competition_description ? (
@@ -156,6 +215,25 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
156
215
  <Text variant="caption" bold style={{ color: theme.colors.primary.default, marginLeft: 4 }}>{competition.competition_code}</Text>
157
216
  </View>
158
217
  ) : null}
218
+ {competition.scheduled_datetime && (
219
+ <CalcuttaCountdown
220
+ targetDate={competition.scheduled_datetime}
221
+ label="Auction starts"
222
+ />
223
+ )}
224
+ {competition.auction_type === 'sealed_bid' && competition.auction_end_datetime && (
225
+ <CalcuttaCountdown
226
+ targetDate={competition.auction_end_datetime}
227
+ label="Bidding closes"
228
+ />
229
+ )}
230
+ <AuctionInfoChips competition={competition} />
231
+ {onShare && (
232
+ <TouchableOpacity onPress={onShare} style={{ flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', marginTop: 10 }}>
233
+ <Ionicons name="share-outline" size={14} color={theme.colors.text.tertiary} />
234
+ <Text variant="caption" color="tertiary" style={{ marginLeft: 4 }}>Share</Text>
235
+ </TouchableOpacity>
236
+ )}
159
237
  </View>
160
238
  );
161
239
 
@@ -188,132 +266,91 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
188
266
  </View>
189
267
  );
190
268
 
191
- case 'cta':
269
+ case 'action-card':
192
270
  return (
193
- <View variant="transparent" style={styles.ctaSection}>
194
- {/* Admin manage button */}
195
- {isAdmin && onManage && ['pending', 'scheduled'].includes(competition.status) && (
196
- <TouchableOpacity onPress={onManage} style={[styles.ctaButton, { backgroundColor: theme.colors.primary.default }]}>
197
- <Ionicons name="settings-outline" size={18} color="#FFF" />
198
- <Text variant="body" bold style={{ color: '#FFF', marginLeft: 8 }}>Manage Competition</Text>
199
- </TouchableOpacity>
200
- )}
201
-
202
- {/* Admin start sweepstakes button */}
203
- {isAdmin && isSweepstakes && competition.status === 'scheduled' && (
204
- <TouchableOpacity
205
- onPress={async () => {
206
- setStartingComp(true);
207
- try {
271
+ <View variant="transparent" style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: 12 }}>
272
+ <CalcuttaActionCard
273
+ competition={competition}
274
+ lifecycleState={lifecycleState}
275
+ hasJoined={hasJoined}
276
+ escrowBalance={Number(escrow?.escrow_balance ?? 0)}
277
+ isAdmin={isAdmin}
278
+ onJoin={onJoin ? async () => {
279
+ setJoining(true);
280
+ try { await onJoin(); await refresh(); fetchEscrow(); } catch {}
281
+ setJoining(false);
282
+ } : undefined}
283
+ onDepositEscrow={undefined}
284
+ onManage={onManage}
285
+ onStartAuction={isAdmin && competition.status === 'scheduled' ? async () => {
286
+ setStartingComp(true);
287
+ try {
288
+ if (isSweepstakes) {
208
289
  await startSweepstakesCompetition(calcutta_competition_id);
209
290
  await refresh();
210
- } catch {}
211
- setStartingComp(false);
212
- }}
213
- disabled={startingComp}
214
- style={[styles.ctaButton, { backgroundColor: '#8B5CF6', opacity: startingComp ? 0.7 : 1 }]}
215
- >
216
- {startingComp ? (
217
- <ActivityIndicator size="small" color="#FFF" />
218
- ) : (
219
- <>
220
- <Ionicons name="play-circle-outline" size={18} color="#FFF" />
221
- <Text variant="body" bold style={{ color: '#FFF', marginLeft: 8 }}>
222
- Start Competition{unassignedItemCount > 0 ? ` (${unassignedItemCount} unassigned)` : ''}
223
- </Text>
224
- </>
225
- )}
226
- </TouchableOpacity>
227
- )}
228
-
229
- {/* Join button */}
230
- {isJoinable && !hasJoined && onJoin && (
231
- <TouchableOpacity
232
- onPress={async () => {
233
- setJoining(true);
234
- try {
235
- await onJoin();
236
- await refresh();
237
- } catch {}
238
- setJoining(false);
239
- }}
240
- disabled={joining}
241
- style={[styles.ctaButton, { backgroundColor: '#10B981', opacity: joining ? 0.7 : 1 }]}
242
- >
243
- {joining ? (
244
- <ActivityIndicator size="small" color="#FFF" />
291
+ } else {
292
+ await startCalcuttaAuction(calcutta_competition_id);
293
+ // Socket will broadcast CALCUTTA_ITEM_ACTIVE which triggers countdown
294
+ // refresh happens after countdown completes
295
+ }
296
+ } catch {}
297
+ setStartingComp(false);
298
+ } : undefined}
299
+ joining={joining}
300
+ />
301
+ {/* Sweepstakes: show assigned item below action card */}
302
+ {isSweepstakes && myAssignedItem && (
303
+ <View variant="transparent" style={[styles.assignedItemCard, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle, marginTop: 10 }]}>
304
+ {resolveItemImageUrl(myAssignedItem.item_image) ? (
305
+ <Image source={{ uri: resolveItemImageUrl(myAssignedItem.item_image)! }} style={styles.assignedItemImage} resizeMode="cover" />
245
306
  ) : (
246
- <Text variant="body" bold style={{ color: '#FFF' }}>
247
- {isSweepstakes
248
- ? (isFree ? 'Join Free — Get a Random Team!' : `Join — ${formatCurrency(entryFee, competition.market_type)}`)
249
- : (isFree ? 'Join - Free!' : `Join - ${formatCurrency(entryFee, competition.market_type)}`)}
250
- </Text>
251
- )}
252
- </TouchableOpacity>
253
- )}
254
-
255
- {/* Already joined badge + assigned item (sweepstakes) + leave */}
256
- {hasJoined && (
257
- <View variant="transparent" style={{ gap: 10 }}>
258
- <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
259
- <View variant="transparent" style={[styles.joinedBadge, { flex: 1, backgroundColor: '#10B98115' }]}>
260
- <Ionicons name="checkmark-circle" size={16} color="#10B981" />
261
- <Text variant="caption" bold style={{ color: '#10B981', marginLeft: 6 }}>You're in!</Text>
262
- </View>
263
- {onLeave && canLeave && (
264
- <TouchableOpacity
265
- onPress={async () => {
266
- setLeaving(true);
267
- try {
268
- await onLeave();
269
- await refresh();
270
- } catch {}
271
- setLeaving(false);
272
- }}
273
- disabled={leaving}
274
- style={[styles.ctaButtonOutline, { borderColor: theme.colors.status.error, paddingHorizontal: 16, opacity: leaving ? 0.7 : 1 }]}
275
- >
276
- {leaving ? (
277
- <ActivityIndicator size="small" color={theme.colors.status.error} />
278
- ) : (
279
- <Text variant="caption" bold style={{ color: theme.colors.status.error }}>Leave</Text>
280
- )}
281
- </TouchableOpacity>
282
- )}
283
- </View>
284
- {/* Sweepstakes: show assigned item */}
285
- {isSweepstakes && myAssignedItem && (
286
- <View variant="transparent" style={[styles.assignedItemCard, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle }]}>
287
- {resolveItemImageUrl(myAssignedItem.item_image) ? (
288
- <Image
289
- source={{ uri: resolveItemImageUrl(myAssignedItem.item_image)! }}
290
- style={styles.assignedItemImage}
291
- resizeMode="cover"
292
- />
293
- ) : (
294
- <View variant="transparent" style={[styles.assignedItemImage, { backgroundColor: theme.colors.primary.subtle, alignItems: 'center', justifyContent: 'center' }]}>
295
- <Ionicons name="trophy" size={20} color={theme.colors.primary.default} />
296
- </View>
297
- )}
298
- <View variant="transparent" style={{ flex: 1, marginLeft: 12 }}>
299
- <Text variant="caption" color="tertiary">Your Team</Text>
300
- <Text variant="body" bold>{myAssignedItem.item_name}</Text>
301
- {myAssignedItem.seed != null && (
302
- <Text variant="caption" color="tertiary">Seed #{myAssignedItem.seed}</Text>
303
- )}
304
- </View>
307
+ <View variant="transparent" style={[styles.assignedItemImage, { backgroundColor: theme.colors.primary.subtle, alignItems: 'center', justifyContent: 'center' }]}>
308
+ <Ionicons name="trophy" size={20} color={theme.colors.primary.default} />
305
309
  </View>
306
310
  )}
311
+ <View variant="transparent" style={{ flex: 1, marginLeft: 12 }}>
312
+ <Text variant="caption" color="tertiary">Your Item</Text>
313
+ <Text variant="body" bold>{myAssignedItem.item_name}</Text>
314
+ {myAssignedItem.seed != null && <Text variant="caption" color="tertiary">Seed #{myAssignedItem.seed}</Text>}
315
+ </View>
307
316
  </View>
308
317
  )}
318
+ </View>
319
+ );
309
320
 
310
- {/* Share button */}
311
- {onShare && (
312
- <TouchableOpacity onPress={onShare} style={[styles.ctaButtonOutline, { borderColor: theme.colors.border.subtle }]}>
313
- <Ionicons name="share-outline" size={16} color={theme.colors.text.secondary} />
314
- <Text variant="caption" bold color="secondary" style={{ marginLeft: 6 }}>Share</Text>
315
- </TouchableOpacity>
316
- )}
321
+ case 'escrow':
322
+ return (
323
+ <View variant="transparent" style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
324
+ <EscrowWidget
325
+ calcutta_competition_id={calcutta_competition_id}
326
+ market_type={competition.market_type}
327
+ max_escrow={competition.max_escrow}
328
+ player_balance={player_balance}
329
+ onUpdate={() => { refresh(); fetchEscrow(); }}
330
+ initialExpanded={Number(escrow?.escrow_balance ?? 0) <= 0}
331
+ />
332
+ </View>
333
+ );
334
+
335
+ case 'leave':
336
+ return (
337
+ <View variant="transparent" style={{ alignItems: 'center', paddingVertical: 16, paddingBottom: 40 }}>
338
+ <TouchableOpacity
339
+ onPress={async () => {
340
+ if (!onLeave) return;
341
+ setLeaving(true);
342
+ try { await onLeave(); await refresh(); } catch {}
343
+ setLeaving(false);
344
+ }}
345
+ disabled={leaving}
346
+ style={{ opacity: leaving ? 0.5 : 1 }}
347
+ >
348
+ {leaving ? (
349
+ <ActivityIndicator size="small" color={theme.colors.status.error} />
350
+ ) : (
351
+ <Text variant="caption" style={{ color: theme.colors.status.error }}>Leave Competition</Text>
352
+ )}
353
+ </TouchableOpacity>
317
354
  </View>
318
355
  );
319
356
 
@@ -392,6 +429,7 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
392
429
  color: theme.colors.text.primary,
393
430
  paddingHorizontal: 12,
394
431
  fontSize: 14,
432
+ lineHeight: 19,
395
433
  marginBottom: 8,
396
434
  }}
397
435
  placeholder="Search by team or owner..."
@@ -442,7 +480,7 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
442
480
  {items.map(item => (
443
481
  <View key={item.calcutta_auction_item_id} variant="transparent" style={[styles.itemChip, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle }]}>
444
482
  <Text variant="caption" bold numberOfLines={1}>{item.item_name}</Text>
445
- {item.seed != null && <Text variant="caption" color="tertiary" style={{ fontSize: 10 }}>#{item.seed}</Text>}
483
+ {item.seed != null && <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>#{item.seed}</Text>}
446
484
  </View>
447
485
  ))}
448
486
  </ScrollView>
@@ -452,9 +490,15 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
452
490
  case 'participants-header':
453
491
  return (
454
492
  <View variant="transparent" style={[styles.section, { borderColor: theme.colors.border.subtle, paddingBottom: 0 }]}>
455
- <Text variant="body" bold style={styles.sectionTitle}>
456
- Players ({participants.length})
457
- </Text>
493
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}>
494
+ <Text variant="body" bold>Players ({participants.length})</Text>
495
+ {onlinePlayers.length > 0 && (
496
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', marginLeft: 10, backgroundColor: '#10B98115', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10 }}>
497
+ <View variant="transparent" style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#10B981', marginRight: 4 }} />
498
+ <Text variant="caption" bold style={{ color: '#10B981' }}>{onlinePlayers.length} online</Text>
499
+ </View>
500
+ )}
501
+ </View>
458
502
  </View>
459
503
  );
460
504
 
@@ -473,17 +517,23 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
473
517
  const enriched = enrichedPlayers[p.player_id];
474
518
  const username = enriched?.username || enriched?.show_name || `Player ${p.player_id.slice(0, 6)}`;
475
519
  const profilePic = enriched?.profile_pic;
520
+ const isOnline = onlinePlayerIds.has(p.player_id);
476
521
  return (
477
522
  <View key={p.calcutta_participant_id} variant="transparent" style={[styles.participantRow, i < participants.length - 1 && { borderBottomWidth: 1, borderColor: theme.colors.border.subtle }]}>
478
- {profilePic ? (
479
- <Image source={{ uri: profilePic }} style={[styles.avatarCircle, { overflow: 'hidden' }]} />
480
- ) : (
481
- <View variant="transparent" style={[styles.avatarCircle, { backgroundColor: theme.colors.primary.subtle }]}>
482
- <Text variant="caption" bold style={{ color: theme.colors.primary.default }}>
483
- {username.charAt(0).toUpperCase()}
484
- </Text>
485
- </View>
486
- )}
523
+ <View variant="transparent" style={{ position: 'relative' }}>
524
+ {profilePic ? (
525
+ <Image source={{ uri: profilePic }} style={[styles.avatarCircle, { overflow: 'hidden' }]} />
526
+ ) : (
527
+ <View variant="transparent" style={[styles.avatarCircle, { backgroundColor: theme.colors.primary.subtle }]}>
528
+ <Text variant="caption" bold style={{ color: theme.colors.primary.default }}>
529
+ {username.charAt(0).toUpperCase()}
530
+ </Text>
531
+ </View>
532
+ )}
533
+ {isOnline && (
534
+ <View variant="transparent" style={{ position: 'absolute', bottom: 0, right: 0, width: 10, height: 10, borderRadius: 5, backgroundColor: '#10B981', borderWidth: 2, borderColor: theme.colors.surface.base }} />
535
+ )}
536
+ </View>
487
537
  <View variant="transparent" style={{ flex: 1 }}>
488
538
  <Text variant="body">{username}</Text>
489
539
  <Text variant="caption" color="tertiary">
@@ -492,7 +542,7 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
492
542
  </View>
493
543
  {p.player_id == player_id && (
494
544
  <View variant="transparent" style={[styles.youBadge, { backgroundColor: theme.colors.primary.subtle }]}>
495
- <Text variant="caption" bold style={{ color: theme.colors.primary.default, fontSize: 10 }}>YOU</Text>
545
+ <Text variant="caption" bold style={{ color: theme.colors.primary.default, fontSize: 10, lineHeight: 13 }}>YOU</Text>
496
546
  </View>
497
547
  )}
498
548
  </View>
@@ -506,27 +556,44 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
506
556
  }
507
557
  };
508
558
 
559
+ const socketColor = socketState === 'authenticated' ? '#10B981'
560
+ : socketState === 'connected' ? '#F59E0B'
561
+ : socketState === 'connecting' ? '#3B82F6'
562
+ : '#EF4444';
563
+
509
564
  return (
510
565
  <View variant="base" style={styles.container}>
511
- {/* Header bar — hidden for sweepstakes (title is in the hero) */}
512
- {!isSweepstakes && (
513
- <View variant="transparent" style={[styles.headerBar, { borderColor: theme.colors.border.subtle }]}>
514
- <TouchableOpacity onPress={onClose} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
515
- <Ionicons name="arrow-back" size={24} color={theme.colors.text.primary} />
516
- </TouchableOpacity>
517
- <Text variant="body" bold style={{ flex: 1, marginLeft: 12 }} numberOfLines={1}>{competition.competition_name}</Text>
518
- </View>
519
- )}
566
+ {/* Socket debug bar */}
567
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 6, backgroundColor: socketColor + '15', borderBottomWidth: 1, borderColor: socketColor + '30' }}>
568
+ <View variant="transparent" style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: socketColor, marginRight: 6 }} />
569
+ <Text variant="caption" style={{ color: socketColor }}>
570
+ Socket: {socketState} {onlinePlayers.length > 0 ? `· ${onlinePlayers.length} in room` : ''}
571
+ </Text>
572
+ {!access_token && <Text variant="caption" style={{ color: '#EF4444', marginLeft: 8 }}>No auth token</Text>}
573
+ </View>
520
574
 
521
575
  <FlatList
522
576
  data={sections}
523
577
  keyExtractor={(item) => item.key}
524
578
  renderItem={renderSection}
579
+ extraData={`${escrowExpanded}-${onlinePlayers.length}`}
525
580
  showsVerticalScrollIndicator={false}
526
581
  style={{ backgroundColor: theme.colors.surface.base }}
527
582
  contentContainerStyle={{ paddingBottom: Platform.OS === 'web' ? 300 : 40 }}
528
583
  keyboardShouldPersistTaps="handled"
529
584
  />
585
+
586
+ {/* Auction starting countdown overlay */}
587
+ <AuctionCountdownOverlay
588
+ visible={showCountdown}
589
+ competitionName={competition?.competition_name}
590
+ firstItem={countdownItem}
591
+ countdownFrom={5}
592
+ onComplete={() => {
593
+ setShowCountdown(false);
594
+ refresh();
595
+ }}
596
+ />
530
597
  </View>
531
598
  );
532
599
  };
@@ -66,10 +66,25 @@ export const CalcuttaEscrow: React.FC<CalcuttaEscrowComponentProps> = ({
66
66
  const hasBalance = player_balance != null;
67
67
  const insufficientBalance = hasBalance && action === 'transfer_in' && numericAmount > Number(player_balance);
68
68
  const shortfall = insufficientBalance ? numericAmount - Number(player_balance) : 0;
69
+ const exceedsBudget = action === 'transfer_in' && budgetRemaining !== null && numericAmount > budgetRemaining;
70
+ const exceedsWithdraw = action === 'transfer_out' && numericAmount > available;
71
+ const [actionError, setActionError] = useState('');
72
+
73
+ const isInvalidAmount = numericAmount <= 0 || exceedsBudget || exceedsWithdraw || (insufficientBalance && !onDepositFunds);
74
+
75
+ const getValidationMessage = (): string => {
76
+ if (numericAmount <= 0) return '';
77
+ if (exceedsBudget) return `Budget cap: ${formatCurrency(Number(max_escrow), market_type)}. You can deposit up to ${formatCurrency(budgetRemaining ?? 0, market_type)} more.`;
78
+ if (exceedsWithdraw) return `You only have ${formatCurrency(available, market_type)} available to withdraw.`;
79
+ if (insufficientBalance && !onDepositFunds) return `Insufficient wallet balance. You have ${formatCurrency(player_balance, market_type)}.`;
80
+ return '';
81
+ };
82
+
83
+ const validationMessage = getValidationMessage();
69
84
 
70
85
  const handleAction = async () => {
71
86
  if (isNaN(numericAmount) || numericAmount <= 0) return;
72
- if (action === 'transfer_in' && budgetRemaining !== null && numericAmount > budgetRemaining) return;
87
+ setActionError('');
73
88
  if (insufficientBalance && onDepositFunds) {
74
89
  onDepositFunds(Math.ceil(shortfall * 100) / 100);
75
90
  return;
@@ -84,12 +99,22 @@ export const CalcuttaEscrow: React.FC<CalcuttaEscrowComponentProps> = ({
84
99
  if (result) {
85
100
  onUpdate(result);
86
101
  setAmount('');
102
+ setActionError('');
87
103
  }
88
- } catch {}
104
+ } catch (e: any) {
105
+ setActionError(e?.message || 'Transfer failed. Please try again.');
106
+ }
89
107
  };
90
108
 
91
109
  const handleQuickAmount = (amt: number) => {
92
- setAmount(String(amt));
110
+ // Cap quick amount to budget remaining for deposits
111
+ if (action === 'transfer_in' && budgetRemaining !== null) {
112
+ setAmount(String(Math.min(amt, budgetRemaining)));
113
+ } else if (action === 'transfer_out') {
114
+ setAmount(String(Math.min(amt, available)));
115
+ } else {
116
+ setAmount(String(amt));
117
+ }
93
118
  };
94
119
 
95
120
  // ============================================
@@ -278,14 +303,14 @@ export const CalcuttaEscrow: React.FC<CalcuttaEscrowComponentProps> = ({
278
303
  </View>
279
304
  <TouchableOpacity
280
305
  style={[styles.submitBtn, {
281
- backgroundColor: insufficientBalance
306
+ backgroundColor: insufficientBalance && onDepositFunds
282
307
  ? theme.colors.status.error
283
- : (loading || !amount || numericAmount <= 0 || (action === 'transfer_in' && budgetFullyDeposited))
308
+ : (loading || isInvalidAmount)
284
309
  ? theme.colors.surface.elevated
285
310
  : theme.colors.primary.default,
286
311
  }]}
287
312
  onPress={handleAction}
288
- disabled={loading || !amount || numericAmount <= 0 || (action === 'transfer_in' && budgetFullyDeposited)}
313
+ disabled={loading || (isInvalidAmount && !(insufficientBalance && onDepositFunds))}
289
314
  activeOpacity={0.7}
290
315
  >
291
316
  {loading ? (
@@ -295,13 +320,13 @@ export const CalcuttaEscrow: React.FC<CalcuttaEscrowComponentProps> = ({
295
320
  <Ionicons
296
321
  name={action === 'transfer_in' ? 'arrow-down-circle' : 'arrow-up-circle'}
297
322
  size={20}
298
- color={(!amount || numericAmount <= 0) ? theme.colors.text.tertiary : '#FFFFFF'}
323
+ color={isInvalidAmount ? theme.colors.text.tertiary : '#FFFFFF'}
299
324
  />
300
325
  <Text
301
326
  variant="body"
302
327
  bold
303
328
  style={{
304
- color: (!amount || numericAmount <= 0) ? theme.colors.text.tertiary : '#FFFFFF',
329
+ color: isInvalidAmount ? theme.colors.text.tertiary : '#FFFFFF',
305
330
  marginLeft: 6,
306
331
  }}
307
332
  >
@@ -316,8 +341,18 @@ export const CalcuttaEscrow: React.FC<CalcuttaEscrowComponentProps> = ({
316
341
  </TouchableOpacity>
317
342
  </View>
318
343
 
319
- {/* Insufficient balance */}
320
- {insufficientBalance && (
344
+ {/* Validation message (budget cap, withdraw limit, etc.) */}
345
+ {validationMessage ? (
346
+ <View variant="transparent" style={[styles.warningRow, { backgroundColor: '#D9770615' }]}>
347
+ <Ionicons name="information-circle-outline" size={16} color="#D97706" />
348
+ <Text variant="caption" style={{ color: '#D97706', marginLeft: 8, flex: 1 }}>
349
+ {validationMessage}
350
+ </Text>
351
+ </View>
352
+ ) : null}
353
+
354
+ {/* Insufficient wallet balance */}
355
+ {insufficientBalance && !validationMessage && (
321
356
  <View variant="transparent" style={[styles.warningRow, { backgroundColor: theme.colors.status.error + '10' }]}>
322
357
  <Ionicons name="warning-outline" size={16} color={theme.colors.status.error} />
323
358
  <Text variant="caption" style={{ color: theme.colors.status.error, marginLeft: 8, flex: 1 }}>
@@ -326,12 +361,12 @@ export const CalcuttaEscrow: React.FC<CalcuttaEscrowComponentProps> = ({
326
361
  </View>
327
362
  )}
328
363
 
329
- {/* Error */}
330
- {error && (
364
+ {/* API error */}
365
+ {(error || actionError) && (
331
366
  <View variant="transparent" style={[styles.warningRow, { backgroundColor: theme.colors.status.error + '10' }]}>
332
367
  <Ionicons name="alert-circle-outline" size={16} color={theme.colors.status.error} />
333
368
  <Text variant="caption" style={{ color: theme.colors.status.error, marginLeft: 8, flex: 1 }}>
334
- {error}
369
+ {actionError || error}
335
370
  </Text>
336
371
  </View>
337
372
  )}
@@ -442,6 +477,7 @@ const styles = StyleSheet.create({
442
477
  input: {
443
478
  flex: 1,
444
479
  fontSize: 18,
480
+ lineHeight: 24,
445
481
  height: 48,
446
482
  padding: 0,
447
483
  },