@bettoredge/calcutta 0.2.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.
Files changed (38) hide show
  1. package/package.json +46 -0
  2. package/src/components/CalcuttaAuction.tsx +453 -0
  3. package/src/components/CalcuttaAuctionItem.tsx +292 -0
  4. package/src/components/CalcuttaBidInput.tsx +214 -0
  5. package/src/components/CalcuttaCard.tsx +131 -0
  6. package/src/components/CalcuttaDetail.tsx +377 -0
  7. package/src/components/CalcuttaEscrow.tsx +464 -0
  8. package/src/components/CalcuttaItemResults.tsx +207 -0
  9. package/src/components/CalcuttaLeaderboard.tsx +179 -0
  10. package/src/components/CalcuttaPayoutPreview.tsx +194 -0
  11. package/src/components/CalcuttaRoundResults.tsx +250 -0
  12. package/src/components/CalcuttaTemplateSelector.tsx +124 -0
  13. package/src/components/sealed/AuctionResultsModal.tsx +165 -0
  14. package/src/components/sealed/EscrowBottomSheet.tsx +185 -0
  15. package/src/components/sealed/SealedBidAuction.tsx +541 -0
  16. package/src/components/sealed/SealedBidHeader.tsx +116 -0
  17. package/src/components/sealed/SealedBidInfoTab.tsx +247 -0
  18. package/src/components/sealed/SealedBidItemCard.tsx +385 -0
  19. package/src/components/sealed/SealedBidItemsTab.tsx +235 -0
  20. package/src/components/sealed/SealedBidMyBidsTab.tsx +512 -0
  21. package/src/components/sealed/SealedBidPlayersTab.tsx +220 -0
  22. package/src/components/sealed/SealedBidStatusBar.tsx +415 -0
  23. package/src/components/sealed/SealedBidTabBar.tsx +172 -0
  24. package/src/helpers/formatting.ts +56 -0
  25. package/src/helpers/lifecycleState.ts +71 -0
  26. package/src/helpers/payout.ts +39 -0
  27. package/src/helpers/validation.ts +64 -0
  28. package/src/hooks/useCalcuttaAuction.ts +164 -0
  29. package/src/hooks/useCalcuttaBid.ts +43 -0
  30. package/src/hooks/useCalcuttaCompetition.ts +63 -0
  31. package/src/hooks/useCalcuttaEscrow.ts +52 -0
  32. package/src/hooks/useCalcuttaItemImages.ts +79 -0
  33. package/src/hooks/useCalcuttaPlayers.ts +46 -0
  34. package/src/hooks/useCalcuttaResults.ts +58 -0
  35. package/src/hooks/useCalcuttaSocket.ts +131 -0
  36. package/src/hooks/useCalcuttaTemplates.ts +36 -0
  37. package/src/index.ts +74 -0
  38. package/src/types.ts +31 -0
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@bettoredge/calcutta",
3
+ "version": "0.2.0",
4
+ "description": "Calcutta auction competition components for BettorEdge applications",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "!**/__tests__",
10
+ "!**/__fixtures__",
11
+ "!**/__mocks__"
12
+ ],
13
+ "scripts": {
14
+ "clean": "rm -rf lib",
15
+ "build": "tsc",
16
+ "prepare": "tsc",
17
+ "prepublishOnly": "tsc"
18
+ },
19
+ "keywords": [
20
+ "react-native",
21
+ "calcutta",
22
+ "auction",
23
+ "bettorEdge"
24
+ ],
25
+ "author": "BettorEdge",
26
+ "license": "ISC",
27
+ "private": false,
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "@bettoredge/styles": "^0.4.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "^19.0.0",
36
+ "@types/react-native": "^0.73.0",
37
+ "typescript": "~5.8.3"
38
+ },
39
+ "peerDependencies": {
40
+ "react": ">=18.3.1",
41
+ "react-native": ">=0.76.5",
42
+ "@expo/vector-icons": "*",
43
+ "@bettoredge/api": ">=0.8.0",
44
+ "@bettoredge/types": ">=0.5.0"
45
+ }
46
+ }
@@ -0,0 +1,453 @@
1
+ import React, { useEffect, useState, useRef, useCallback } from 'react';
2
+ import { StyleSheet, TouchableOpacity, ActivityIndicator, FlatList, ScrollView } from 'react-native';
3
+ import { View, Text, useTheme } from '@bettoredge/styles';
4
+ import { Ionicons } from '@expo/vector-icons';
5
+ import type { CalcuttaEscrowProps } from '@bettoredge/types';
6
+ import { useCalcuttaAuction } from '../hooks/useCalcuttaAuction';
7
+ import { useCalcuttaCompetition } from '../hooks/useCalcuttaCompetition';
8
+ import { useCalcuttaEscrow } from '../hooks/useCalcuttaEscrow';
9
+ import { useCalcuttaBid } from '../hooks/useCalcuttaBid';
10
+ import { useCalcuttaSocket, CalcuttaPresence, CalcuttaPresencePlayer } from '../hooks/useCalcuttaSocket';
11
+ import { useCalcuttaItemImages } from '../hooks/useCalcuttaItemImages';
12
+ import { formatCurrency, getStatusLabel } from '../helpers/formatting';
13
+ import { CalcuttaAuctionItem } from './CalcuttaAuctionItem';
14
+ import { CalcuttaEscrow } from './CalcuttaEscrow';
15
+ import { SealedBidAuction } from './sealed/SealedBidAuction';
16
+
17
+ export interface CalcuttaAuctionProps {
18
+ calcutta_competition_id: string;
19
+ player_id?: string;
20
+ onClose: () => void;
21
+ getSocket?: () => WebSocket | null;
22
+ getAccessToken?: () => string | undefined;
23
+ getDeviceId?: () => string | undefined;
24
+ player_data?: { username: string; profile_pic?: string };
25
+ onPauseAuction?: () => void;
26
+ onResumeAuction?: () => void;
27
+ onNextItem?: () => void;
28
+ player_balance?: number;
29
+ onDepositFunds?: (amount: number) => void;
30
+ onManage?: () => void;
31
+ onJoin?: () => void | Promise<void>;
32
+ onLeave?: () => void | Promise<void>;
33
+ }
34
+
35
+ // Inner component for live auction (original layout)
36
+ const LiveAuction: React.FC<CalcuttaAuctionProps> = ({
37
+ calcutta_competition_id,
38
+ player_id,
39
+ onClose,
40
+ getSocket,
41
+ getAccessToken,
42
+ getDeviceId,
43
+ player_data,
44
+ onPauseAuction,
45
+ onResumeAuction,
46
+ onNextItem,
47
+ player_balance,
48
+ onDepositFunds,
49
+ }) => {
50
+ const { theme } = useTheme();
51
+ const {
52
+ loading,
53
+ competition,
54
+ items,
55
+ my_bids,
56
+ paused,
57
+ stopPolling,
58
+ handleBidUpdate,
59
+ handleItemActive,
60
+ handleItemSold,
61
+ handleItemUnsold,
62
+ handleAuctionPaused,
63
+ handleAuctionResumed,
64
+ handleAuctionClosed,
65
+ } = useCalcuttaAuction(calcutta_competition_id, 5000, player_id);
66
+
67
+ const { escrow, fetchEscrow } = useCalcuttaEscrow(calcutta_competition_id);
68
+ const { placeBid, loading: bidLoading } = useCalcuttaBid();
69
+ const { images: itemImages } = useCalcuttaItemImages(items);
70
+
71
+ const isAdmin = player_id != null && competition?.admin_id === player_id;
72
+
73
+ // Socket connection for live auctions
74
+ const noopSocket = useCallback(() => null, []);
75
+ const noopString = useCallback(() => undefined, []);
76
+ const { connected, presence } = useCalcuttaSocket(
77
+ calcutta_competition_id,
78
+ player_data || { username: '' },
79
+ getSocket ? getSocket : noopSocket,
80
+ getAccessToken ? getAccessToken : noopString,
81
+ getDeviceId ? getDeviceId : noopString,
82
+ {
83
+ onBidUpdate: handleBidUpdate,
84
+ onItemActive: handleItemActive,
85
+ onItemSold: handleItemSold,
86
+ onItemUnsold: handleItemUnsold,
87
+ onAuctionPaused: handleAuctionPaused,
88
+ onAuctionResumed: handleAuctionResumed,
89
+ onAuctionClosed: handleAuctionClosed,
90
+ },
91
+ );
92
+
93
+ // For live auctions with socket, stop REST polling once connected
94
+ useEffect(() => {
95
+ if (connected) {
96
+ stopPolling();
97
+ }
98
+ }, [connected, stopPolling]);
99
+
100
+ useEffect(() => {
101
+ fetchEscrow();
102
+ return () => stopPolling();
103
+ }, [fetchEscrow, stopPolling]);
104
+
105
+ const handlePlaceBid = async (calcutta_auction_item_id: string, amount: number) => {
106
+ try {
107
+ await placeBid(calcutta_auction_item_id, amount);
108
+ fetchEscrow();
109
+ } catch {
110
+ // Error handled in hook
111
+ }
112
+ };
113
+
114
+ const [escrowExpanded, setEscrowExpanded] = useState(true);
115
+ const [hasAutoCollapsed, setHasAutoCollapsed] = useState(false);
116
+
117
+ // Auto-collapse escrow panel once funds are loaded
118
+ useEffect(() => {
119
+ if (!hasAutoCollapsed && escrow && Number(escrow.escrow_balance) > 0) {
120
+ setEscrowExpanded(false);
121
+ setHasAutoCollapsed(true);
122
+ }
123
+ }, [escrow, hasAutoCollapsed]);
124
+
125
+ // Presence bar visibility
126
+ const [showPresenceList, setShowPresenceList] = useState(false);
127
+
128
+ if (loading && !competition) {
129
+ return (
130
+ <View variant="transparent" style={styles.loadingContainer}>
131
+ <ActivityIndicator size="large" color={theme.colors.primary.default} />
132
+ </View>
133
+ );
134
+ }
135
+
136
+ if (!competition) {
137
+ return (
138
+ <View variant="transparent" style={styles.loadingContainer}>
139
+ <Text variant="body" color="secondary">Auction not found</Text>
140
+ </View>
141
+ );
142
+ }
143
+
144
+ return (
145
+ <View variant="transparent" style={styles.container}>
146
+ {/* Header */}
147
+ <View variant="transparent" style={[styles.header, { borderColor: theme.colors.border.subtle }]}>
148
+ <TouchableOpacity onPress={onClose} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
149
+ <Ionicons name="arrow-back" size={24} color={theme.colors.text.primary} />
150
+ </TouchableOpacity>
151
+ <View variant="transparent" style={styles.headerInfo}>
152
+ <Text variant="body" bold numberOfLines={1}>
153
+ {competition.competition_name}
154
+ </Text>
155
+ <Text variant="caption" color="tertiary">
156
+ Live Auction
157
+ {' \u00B7 '}
158
+ {paused ? 'Paused' : getStatusLabel(competition.auction_status)}
159
+ </Text>
160
+ </View>
161
+
162
+ {/* Admin controls for live auction */}
163
+ {isAdmin && competition.auction_status === 'in_progress' && (
164
+ <View variant="transparent" style={styles.adminControls}>
165
+ {onPauseAuction && (
166
+ <TouchableOpacity
167
+ style={[styles.adminButton, { backgroundColor: theme.colors.status.error + '20' }]}
168
+ onPress={onPauseAuction}
169
+ >
170
+ <Ionicons name="pause" size={14} color={theme.colors.status.error} />
171
+ </TouchableOpacity>
172
+ )}
173
+ {onNextItem && (
174
+ <TouchableOpacity
175
+ style={[styles.adminButton, { backgroundColor: theme.colors.primary.subtle, marginLeft: 6 }]}
176
+ onPress={onNextItem}
177
+ >
178
+ <Ionicons name="play-skip-forward" size={14} color={theme.colors.primary.default} />
179
+ </TouchableOpacity>
180
+ )}
181
+ </View>
182
+ )}
183
+ {isAdmin && paused && onResumeAuction && (
184
+ <TouchableOpacity
185
+ style={[styles.adminButton, { backgroundColor: theme.colors.status.success + '20' }]}
186
+ onPress={onResumeAuction}
187
+ >
188
+ <Ionicons name="play" size={14} color={theme.colors.status.success} />
189
+ </TouchableOpacity>
190
+ )}
191
+ </View>
192
+
193
+ {/* Presence bar (live mode) */}
194
+ {presence.count > 0 && (
195
+ <TouchableOpacity
196
+ activeOpacity={0.7}
197
+ onPress={() => setShowPresenceList(!showPresenceList)}
198
+ style={[styles.presenceBar, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle }]}
199
+ >
200
+ <Ionicons name="people-outline" size={14} color={theme.colors.text.secondary} />
201
+ <Text variant="caption" color="secondary" style={{ marginLeft: 6, marginRight: 8 }}>
202
+ {presence.count} player{presence.count !== 1 ? 's' : ''} live
203
+ </Text>
204
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ flex: 1 }}>
205
+ {presence.players.slice(0, 20).map((p) => (
206
+ <View
207
+ key={p.player_id}
208
+ variant="transparent"
209
+ style={[styles.presenceAvatar, { backgroundColor: theme.colors.primary.subtle }]}
210
+ >
211
+ <Text variant="caption" style={{ fontSize: 9, color: theme.colors.primary.default }}>
212
+ {(p.username || '?').charAt(0).toUpperCase()}
213
+ </Text>
214
+ </View>
215
+ ))}
216
+ </ScrollView>
217
+ </TouchableOpacity>
218
+ )}
219
+
220
+ {/* Pause overlay */}
221
+ {paused && (
222
+ <View
223
+ variant="transparent"
224
+ style={[styles.pauseOverlayBar, { backgroundColor: theme.colors.status.error + '10', borderColor: theme.colors.border.subtle }]}
225
+ >
226
+ <Ionicons name="pause-circle-outline" size={18} color={theme.colors.status.error} />
227
+ <Text variant="caption" bold style={{ marginLeft: 8, color: theme.colors.status.error }}>
228
+ Auction paused by host. Bidding will resume shortly.
229
+ </Text>
230
+ </View>
231
+ )}
232
+
233
+ {/* Rules info card */}
234
+ {(competition.max_escrow || competition.min_spend_pct) && (
235
+ <View
236
+ variant="transparent"
237
+ style={[styles.rulesCard, { backgroundColor: theme.colors.primary.subtle, borderColor: theme.colors.border.subtle }]}
238
+ >
239
+ <Ionicons name="information-circle-outline" size={16} color={theme.colors.primary.default} />
240
+ <View variant="transparent" style={{ marginLeft: 8, flex: 1 }}>
241
+ {competition.max_escrow != null && Number(competition.max_escrow) > 0 && (
242
+ <Text variant="caption" style={{ color: theme.colors.text.secondary }}>
243
+ {'\u2022 '}Player Budget: {formatCurrency(Number(competition.max_escrow), competition.market_type)} — each player can deposit up to this amount
244
+ </Text>
245
+ )}
246
+ {competition.min_spend_pct != null && Number(competition.min_spend_pct) > 0 && (
247
+ <Text variant="caption" style={{ color: theme.colors.text.secondary, marginTop: competition.max_escrow ? 4 : 0 }}>
248
+ {'\u2022 '}Min Spend: {Number(competition.min_spend_pct)}% — you must bid at least {Number(competition.min_spend_pct)}% of your deposited funds or the shortfall is added to the pot
249
+ </Text>
250
+ )}
251
+ </View>
252
+ </View>
253
+ )}
254
+
255
+ {/* Escrow balance - tappable */}
256
+ <TouchableOpacity
257
+ activeOpacity={0.7}
258
+ onPress={() => setEscrowExpanded(!escrowExpanded)}
259
+ style={[styles.escrowBar, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle }]}
260
+ >
261
+ <Ionicons name="wallet-outline" size={16} color={theme.colors.text.secondary} />
262
+ <Text variant="caption" color="secondary" style={styles.escrowLabel}>
263
+ Escrow Balance:
264
+ </Text>
265
+ <Text variant="body" bold>
266
+ {escrow
267
+ ? formatCurrency(escrow.escrow_balance, competition.market_type)
268
+ : formatCurrency(0, competition.market_type)}
269
+ </Text>
270
+ {competition.max_escrow != null && Number(competition.max_escrow) > 0 && escrow && (
271
+ <Text variant="caption" color="tertiary" style={styles.committedLabel}>
272
+ / {formatCurrency(Number(competition.max_escrow), competition.market_type)} budget
273
+ </Text>
274
+ )}
275
+ {escrow && escrow.committed_balance > 0 && !competition.max_escrow && (
276
+ <Text variant="caption" color="tertiary" style={styles.committedLabel}>
277
+ ({formatCurrency(escrow.committed_balance, competition.market_type)} committed)
278
+ </Text>
279
+ )}
280
+ <Ionicons
281
+ name={escrowExpanded ? 'chevron-up' : 'chevron-down'}
282
+ size={14}
283
+ color={theme.colors.text.tertiary}
284
+ style={{ marginLeft: 'auto' }}
285
+ />
286
+ </TouchableOpacity>
287
+
288
+ {/* Inline escrow deposit/withdraw */}
289
+ {escrowExpanded && (
290
+ <CalcuttaEscrow
291
+ calcutta_competition_id={calcutta_competition_id}
292
+ escrow={escrow ?? undefined}
293
+ market_type={competition.market_type}
294
+ max_escrow={competition.max_escrow}
295
+ player_balance={player_balance}
296
+ onDepositFunds={onDepositFunds}
297
+ onUpdate={(updated) => {
298
+ fetchEscrow();
299
+ }}
300
+ />
301
+ )}
302
+
303
+ {/* Items list */}
304
+ <FlatList
305
+ data={items}
306
+ keyExtractor={item => item.calcutta_auction_item_id}
307
+ renderItem={({ item }) => {
308
+ const myBid = my_bids.find(
309
+ b => b.calcutta_auction_item_id === item.calcutta_auction_item_id
310
+ );
311
+ const isActiveItem = competition.current_auction_item_id === item.calcutta_auction_item_id;
312
+ return (
313
+ <CalcuttaAuctionItem
314
+ item={item}
315
+ my_bid={myBid}
316
+ auction_type="live"
317
+ min_bid={competition.min_bid}
318
+ bid_increment={competition.bid_increment}
319
+ escrow_balance={escrow?.escrow_balance ?? 0}
320
+ onPlaceBid={(amount) => handlePlaceBid(item.calcutta_auction_item_id, amount)}
321
+ is_active_item={isActiveItem}
322
+ is_paused={paused}
323
+ itemImage={itemImages[item.item_id]}
324
+ />
325
+ );
326
+ }}
327
+ ListEmptyComponent={
328
+ <Text variant="caption" color="tertiary" style={styles.emptyText}>
329
+ No auction items available
330
+ </Text>
331
+ }
332
+ />
333
+ </View>
334
+ );
335
+ };
336
+
337
+ // Main exported component — routes to sealed bid or live layout
338
+ export const CalcuttaAuction: React.FC<CalcuttaAuctionProps> = (props) => {
339
+ const { theme } = useTheme();
340
+
341
+ // Peek at auction type to decide which layout to render.
342
+ // Use useCalcuttaCompetition for a single fetch (no polling).
343
+ const { loading, competition } = useCalcuttaCompetition(props.calcutta_competition_id);
344
+
345
+ if (loading && !competition) {
346
+ return (
347
+ <View variant="transparent" style={styles.loadingContainer}>
348
+ <ActivityIndicator size="large" color={theme.colors.primary.default} />
349
+ </View>
350
+ );
351
+ }
352
+
353
+ if (!competition) {
354
+ return (
355
+ <View variant="transparent" style={styles.loadingContainer}>
356
+ <Text variant="body" color="secondary">Auction not found</Text>
357
+ </View>
358
+ );
359
+ }
360
+
361
+ if (competition.auction_type === 'sealed_bid') {
362
+ return <SealedBidAuction {...props} />;
363
+ }
364
+
365
+ return <LiveAuction {...props} />;
366
+ };
367
+
368
+ const styles = StyleSheet.create({
369
+ container: {
370
+ flex: 1,
371
+ },
372
+ loadingContainer: {
373
+ flex: 1,
374
+ alignItems: 'center',
375
+ justifyContent: 'center',
376
+ padding: 40,
377
+ },
378
+ header: {
379
+ flexDirection: 'row',
380
+ alignItems: 'center',
381
+ padding: 12,
382
+ borderBottomWidth: 1,
383
+ },
384
+ headerInfo: {
385
+ flex: 1,
386
+ marginLeft: 12,
387
+ },
388
+ adminControls: {
389
+ flexDirection: 'row',
390
+ alignItems: 'center',
391
+ },
392
+ adminButton: {
393
+ width: 32,
394
+ height: 32,
395
+ borderRadius: 16,
396
+ alignItems: 'center',
397
+ justifyContent: 'center',
398
+ },
399
+ presenceBar: {
400
+ flexDirection: 'row',
401
+ alignItems: 'center',
402
+ paddingVertical: 6,
403
+ paddingHorizontal: 12,
404
+ borderBottomWidth: 1,
405
+ },
406
+ presenceAvatar: {
407
+ width: 24,
408
+ height: 24,
409
+ borderRadius: 12,
410
+ alignItems: 'center',
411
+ justifyContent: 'center',
412
+ marginRight: 4,
413
+ },
414
+ pauseOverlayBar: {
415
+ flexDirection: 'row',
416
+ alignItems: 'center',
417
+ justifyContent: 'center',
418
+ paddingVertical: 10,
419
+ paddingHorizontal: 12,
420
+ borderBottomWidth: 1,
421
+ },
422
+ escrowBar: {
423
+ flexDirection: 'row',
424
+ alignItems: 'center',
425
+ padding: 10,
426
+ borderBottomWidth: 1,
427
+ },
428
+ escrowLabel: {
429
+ marginLeft: 6,
430
+ marginRight: 4,
431
+ },
432
+ committedLabel: {
433
+ marginLeft: 6,
434
+ },
435
+ countdownBar: {
436
+ flexDirection: 'row',
437
+ alignItems: 'center',
438
+ justifyContent: 'center',
439
+ paddingVertical: 8,
440
+ paddingHorizontal: 12,
441
+ borderBottomWidth: 1,
442
+ },
443
+ rulesCard: {
444
+ flexDirection: 'row',
445
+ alignItems: 'flex-start',
446
+ padding: 10,
447
+ borderBottomWidth: 1,
448
+ },
449
+ emptyText: {
450
+ textAlign: 'center',
451
+ padding: 20,
452
+ },
453
+ });