@bettoredge/calcutta 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettoredge/calcutta",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Calcutta auction competition components for BettorEdge applications",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -29,7 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@bettoredge/styles": "^0.4.0"
32
+ "@bettoredge/styles": "^0.4.2"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/react": "^19.0.0",
@@ -40,7 +40,7 @@
40
40
  "react": ">=18.3.1",
41
41
  "react-native": ">=0.76.5",
42
42
  "@expo/vector-icons": "*",
43
- "@bettoredge/api": ">=0.8.0",
44
- "@bettoredge/types": ">=0.5.0"
43
+ "@bettoredge/api": ">=0.8.3",
44
+ "@bettoredge/types": ">=0.5.2"
45
45
  }
46
46
  }
@@ -1,5 +1,5 @@
1
- import React, { useEffect, useState, useRef, useCallback } from 'react';
2
- import { StyleSheet, TouchableOpacity, ActivityIndicator, FlatList, ScrollView } from 'react-native';
1
+ import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
2
+ import { StyleSheet, TouchableOpacity, ActivityIndicator, FlatList, ScrollView, Image, TextInput, Platform } from 'react-native';
3
3
  import { View, Text, useTheme } from '@bettoredge/styles';
4
4
  import { Ionicons } from '@expo/vector-icons';
5
5
  import type { CalcuttaEscrowProps } from '@bettoredge/types';
@@ -9,7 +9,8 @@ import { useCalcuttaEscrow } from '../hooks/useCalcuttaEscrow';
9
9
  import { useCalcuttaBid } from '../hooks/useCalcuttaBid';
10
10
  import { useCalcuttaSocket, CalcuttaPresence, CalcuttaPresencePlayer } from '../hooks/useCalcuttaSocket';
11
11
  import { useCalcuttaItemImages } from '../hooks/useCalcuttaItemImages';
12
- import { formatCurrency, getStatusLabel } from '../helpers/formatting';
12
+ import { useCalcuttaPlayers } from '../hooks/useCalcuttaPlayers';
13
+ import { formatCurrency, getStatusLabel, resolveItemImageUrl } from '../helpers/formatting';
13
14
  import { CalcuttaAuctionItem } from './CalcuttaAuctionItem';
14
15
  import { CalcuttaEscrow } from './CalcuttaEscrow';
15
16
  import { SealedBidAuction } from './sealed/SealedBidAuction';
@@ -334,6 +335,308 @@ const LiveAuction: React.FC<CalcuttaAuctionProps> = ({
334
335
  );
335
336
  };
336
337
 
338
+ // Sweepstakes: no auction, show the player's assigned item and prize structure
339
+ const SweepstakesWaiting: React.FC<CalcuttaAuctionProps & { competition: any }> = ({
340
+ calcutta_competition_id,
341
+ player_id,
342
+ onClose,
343
+ onManage,
344
+ competition,
345
+ }) => {
346
+ const { theme } = useTheme();
347
+ const { items, rounds, participants, item_results } = useCalcuttaCompetition(calcutta_competition_id);
348
+ const { images: itemImages } = useCalcuttaItemImages(items);
349
+ const isAdmin = player_id != null && competition?.admin_id == player_id;
350
+
351
+ // Build player lookup for owner usernames
352
+ const ownerIds = items.filter(i => i.winning_player_id).map(i => i.winning_player_id!);
353
+ const { players: enrichedPlayers } = useCalcuttaPlayers(ownerIds);
354
+
355
+ const [searchQuery, setSearchQuery] = useState('');
356
+
357
+ const [expandedRound, setExpandedRound] = useState<string | null>(null);
358
+
359
+ const myItem = items.find(i => i.winning_player_id == player_id);
360
+ const sortedRounds = [...(rounds ?? [])].sort((a, b) => a.round_number - b.round_number);
361
+
362
+ const filteredItems = useMemo(() => {
363
+ if (!searchQuery.trim()) return items;
364
+ const q = searchQuery.toLowerCase().trim();
365
+ return items.filter(item => {
366
+ if (item.item_name.toLowerCase().includes(q)) return true;
367
+ if (item.winning_player_id) {
368
+ const owner = enrichedPlayers[item.winning_player_id];
369
+ const ownerName = (owner?.username || owner?.show_name || '').toLowerCase();
370
+ if (ownerName.includes(q)) return true;
371
+ }
372
+ return false;
373
+ });
374
+ }, [items, searchQuery, enrichedPlayers]);
375
+ const statusLabel = competition.status === 'inprogress' ? 'In Progress'
376
+ : competition.status === 'closed' ? 'Completed' : 'Scheduled';
377
+
378
+ const statusColor = competition.status === 'inprogress' ? '#8B5CF6'
379
+ : competition.status === 'closed' ? '#6B7280'
380
+ : '#10B981';
381
+
382
+ return (
383
+ <View variant="base" style={styles.container}>
384
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 16, paddingBottom: Platform.OS === 'web' ? 300 : 40 }} keyboardShouldPersistTaps="handled">
385
+ {/* Hero with embedded title */}
386
+ <View style={{ position: 'relative' }}>
387
+ {competition.image?.url ? (
388
+ <Image source={{ uri: competition.image.url }} style={{ width: '100%', aspectRatio: 16 / 9 }} resizeMode="cover" />
389
+ ) : (
390
+ <View variant="transparent" style={{ width: '100%', aspectRatio: 16 / 9, backgroundColor: '#1e1b4b' }} />
391
+ )}
392
+ {/* Dark overlay for text readability */}
393
+ <View variant="transparent" style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: '70%', backgroundColor: 'rgba(0,0,0,0.5)' }} />
394
+ {/* Top bar: status badge + manage */}
395
+ <View variant="transparent" style={{ position: 'absolute', top: 12, left: 16, right: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
396
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: statusColor + '30', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, gap: 6 }}>
397
+ <View variant="transparent" style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: statusColor }} />
398
+ <Text variant="caption" bold style={{ color: statusColor }}>{statusLabel}</Text>
399
+ </View>
400
+ {isAdmin && onManage && (
401
+ <TouchableOpacity
402
+ onPress={onManage}
403
+ activeOpacity={0.7}
404
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(0,0,0,0.55)', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 8, borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' }}
405
+ >
406
+ <Ionicons name="settings-outline" size={14} color="#fff" />
407
+ <Text variant="caption" bold style={{ color: '#fff' }}>Manage</Text>
408
+ </TouchableOpacity>
409
+ )}
410
+ </View>
411
+ {/* Title overlay */}
412
+ <View variant="transparent" style={{ position: 'absolute', left: 0, right: 0, bottom: 0, paddingHorizontal: 20, paddingBottom: 28 }}>
413
+ <Text variant="h2" bold style={{ color: '#fff', fontSize: 26, lineHeight: 34, fontWeight: '800', letterSpacing: -0.5 }}>
414
+ {competition.competition_name}
415
+ </Text>
416
+ <Text variant="caption" style={{ color: 'rgba(255,255,255,0.7)', marginTop: 4 }}>
417
+ Sweepstakes
418
+ </Text>
419
+ </View>
420
+ </View>
421
+
422
+ {/* Assigned item */}
423
+ {myItem && (() => {
424
+ const myItemImageUrl = resolveItemImageUrl(myItem.item_image) || itemImages[myItem.item_id]?.url;
425
+ return (
426
+ <View variant="transparent" style={[styles.rulesCard, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle, borderRadius: 10, marginHorizontal: 16 }]}>
427
+ {myItemImageUrl ? (
428
+ <Image source={{ uri: myItemImageUrl }} style={{ width: 40, height: 40, borderRadius: 8 }} resizeMode="cover" />
429
+ ) : (
430
+ <Ionicons name="trophy" size={24} color={theme.colors.primary.default} />
431
+ )}
432
+ <View variant="transparent" style={{ marginLeft: 12, flex: 1 }}>
433
+ <Text variant="caption" color="tertiary">Your Team</Text>
434
+ <Text variant="body" bold>{myItem.item_name}</Text>
435
+ {myItem.seed != null && <Text variant="caption" color="tertiary">Seed #{myItem.seed}</Text>}
436
+ </View>
437
+ </View>
438
+ );
439
+ })()}
440
+
441
+ {/* Stats */}
442
+ <View variant="transparent" style={{ flexDirection: 'row', gap: 12, paddingHorizontal: 16 }}>
443
+ <View variant="transparent" style={[styles.rulesCard, { flex: 1, backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle, borderRadius: 10, alignItems: 'center' }]}>
444
+ <Text variant="caption" color="tertiary">Players</Text>
445
+ <Text variant="body" bold>{participants.length}</Text>
446
+ </View>
447
+ <View variant="transparent" style={[styles.rulesCard, { flex: 1, backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle, borderRadius: 10, alignItems: 'center' }]}>
448
+ <Text variant="caption" color="tertiary">Teams</Text>
449
+ <Text variant="body" bold>{items.length}</Text>
450
+ </View>
451
+ <View variant="transparent" style={[styles.rulesCard, { flex: 1, backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle, borderRadius: 10, alignItems: 'center' }]}>
452
+ <Text variant="caption" color="tertiary">Rounds</Text>
453
+ <Text variant="body" bold>{rounds.length}</Text>
454
+ </View>
455
+ </View>
456
+
457
+ {/* Rounds with results */}
458
+ {sortedRounds.length > 0 && (
459
+ <View variant="transparent" style={{ paddingHorizontal: 16 }}>
460
+ <Text variant="body" bold style={{ marginBottom: 10 }}>Rounds</Text>
461
+ {sortedRounds.map((round: any) => {
462
+ const isExpanded = expandedRound === round.calcutta_round_id;
463
+ const roundResults = item_results.filter(r => r.round_number === round.round_number);
464
+ const advancedIds = roundResults.filter(r => r.result === 'advanced' || r.result === 'won').map(r => r.calcutta_auction_item_id);
465
+ const eliminatedIds = roundResults.filter(r => r.result === 'eliminated').map(r => r.calcutta_auction_item_id);
466
+ const advancedItems = items.filter(i => advancedIds.includes(i.calcutta_auction_item_id));
467
+ const eliminatedItems = items.filter(i => eliminatedIds.includes(i.calcutta_auction_item_id));
468
+ const hasResults = roundResults.length > 0;
469
+ const statusColor = round.status === 'closed' ? theme.colors.status.success
470
+ : round.status === 'inprogress' ? theme.colors.primary.default
471
+ : theme.colors.text.tertiary;
472
+
473
+ return (
474
+ <View key={round.calcutta_round_id} variant="transparent" style={{ borderBottomWidth: 1, borderColor: theme.colors.border.subtle }}>
475
+ <TouchableOpacity
476
+ onPress={() => setExpandedRound(isExpanded ? null : round.calcutta_round_id)}
477
+ activeOpacity={0.7}
478
+ style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 12 }}
479
+ >
480
+ <View variant="transparent" style={{ flex: 1 }}>
481
+ <Text variant="body" bold>{round.round_name}</Text>
482
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', marginTop: 4, gap: 8 }}>
483
+ <View variant="transparent" style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: statusColor }} />
484
+ <Text variant="caption" color="tertiary">
485
+ {round.status === 'closed' ? 'Resolved' : round.status === 'inprogress' ? 'In Progress' : 'Pending'}
486
+ </Text>
487
+ {hasResults && (
488
+ <Text variant="caption" style={{ color: theme.colors.status.success }}>
489
+ {advancedItems.length} advanced
490
+ </Text>
491
+ )}
492
+ </View>
493
+ {round.prize_description && (
494
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', marginTop: 4 }}>
495
+ <Ionicons name="gift-outline" size={12} color={theme.colors.primary.default} />
496
+ <Text variant="caption" style={{ color: theme.colors.primary.default, marginLeft: 4 }}>{round.prize_description}</Text>
497
+ </View>
498
+ )}
499
+ </View>
500
+ <Ionicons name={isExpanded ? 'chevron-up' : 'chevron-down'} size={16} color={theme.colors.text.tertiary} />
501
+ </TouchableOpacity>
502
+
503
+ {isExpanded && (
504
+ <View variant="transparent" style={{ paddingBottom: 12 }}>
505
+ {!hasResults && (
506
+ <Text variant="caption" color="tertiary" style={{ paddingVertical: 8 }}>No results yet</Text>
507
+ )}
508
+ {advancedItems.length > 0 && (
509
+ <>
510
+ <Text variant="caption" bold color="secondary" style={{ marginTop: 4, marginBottom: 4 }}>Advanced ({advancedItems.length})</Text>
511
+ {advancedItems.map(item => {
512
+ const imgUrl = resolveItemImageUrl(item.item_image) || itemImages[item.item_id]?.url;
513
+ const isMe = item.winning_player_id == player_id;
514
+ return (
515
+ <View key={item.calcutta_auction_item_id} variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingLeft: 4 }}>
516
+ {imgUrl ? (
517
+ <Image source={{ uri: imgUrl }} style={{ width: 20, height: 20, borderRadius: 4 }} resizeMode="cover" />
518
+ ) : (
519
+ <Ionicons name="checkmark-circle" size={16} color={theme.colors.status.success} />
520
+ )}
521
+ <Text variant="caption" style={{ marginLeft: 6, flex: 1 }}>{item.item_name}</Text>
522
+ {isMe && <Text variant="caption" bold style={{ color: theme.colors.primary.default }}>YOU</Text>}
523
+ </View>
524
+ );
525
+ })}
526
+ </>
527
+ )}
528
+ {eliminatedItems.length > 0 && (
529
+ <>
530
+ <Text variant="caption" bold color="secondary" style={{ marginTop: 8, marginBottom: 4 }}>Eliminated ({eliminatedItems.length})</Text>
531
+ {eliminatedItems.map(item => {
532
+ const imgUrl = resolveItemImageUrl(item.item_image) || itemImages[item.item_id]?.url;
533
+ const isMe = item.winning_player_id == player_id;
534
+ return (
535
+ <View key={item.calcutta_auction_item_id} variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingLeft: 4, opacity: 0.5 }}>
536
+ {imgUrl ? (
537
+ <Image source={{ uri: imgUrl }} style={{ width: 20, height: 20, borderRadius: 4 }} resizeMode="cover" />
538
+ ) : (
539
+ <Ionicons name="close-circle" size={16} color={theme.colors.status.error} />
540
+ )}
541
+ <Text variant="caption" style={{ marginLeft: 6, flex: 1 }}>{item.item_name}</Text>
542
+ {isMe && <Text variant="caption" bold style={{ color: theme.colors.status.error }}>YOU</Text>}
543
+ </View>
544
+ );
545
+ })}
546
+ </>
547
+ )}
548
+ </View>
549
+ )}
550
+ </View>
551
+ );
552
+ })}
553
+ </View>
554
+ )}
555
+
556
+ {/* All teams list */}
557
+ {items.length > 0 && (
558
+ <View variant="transparent" style={{ paddingHorizontal: 16 }}>
559
+ <Text variant="body" bold style={{ marginBottom: 10 }}>All Teams</Text>
560
+ <TextInput
561
+ style={{
562
+ height: 36,
563
+ borderRadius: 8,
564
+ borderWidth: 1,
565
+ borderColor: theme.colors.border.subtle,
566
+ backgroundColor: theme.colors.surface.input,
567
+ color: theme.colors.text.primary,
568
+ paddingHorizontal: 12,
569
+ fontSize: 14,
570
+ marginBottom: 8,
571
+ }}
572
+ placeholder="Search by team or owner..."
573
+ placeholderTextColor={theme.colors.text.tertiary}
574
+ value={searchQuery}
575
+ onChangeText={setSearchQuery}
576
+ autoCapitalize="none"
577
+ autoCorrect={false}
578
+ onFocus={(e: any) => {
579
+ if (Platform.OS === 'web' && e?.target?.scrollIntoView) {
580
+ setTimeout(() => e.target.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300);
581
+ }
582
+ }}
583
+ />
584
+ {filteredItems.map((item) => {
585
+ const isMyItem = item.winning_player_id == player_id;
586
+ const isEliminated = item.status === 'eliminated';
587
+ const imageUrl = resolveItemImageUrl(item.item_image) || itemImages[item.item_id]?.url;
588
+ const owner = item.winning_player_id ? enrichedPlayers[item.winning_player_id] : undefined;
589
+ const ownerName = owner?.username || owner?.show_name;
590
+ return (
591
+ <View
592
+ key={item.calcutta_auction_item_id}
593
+ variant="transparent"
594
+ style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderBottomWidth: 1, borderColor: theme.colors.border.subtle, opacity: isEliminated ? 0.5 : 1 }}
595
+ >
596
+ {imageUrl ? (
597
+ <Image source={{ uri: imageUrl }} style={{ width: 28, height: 28, borderRadius: 6 }} resizeMode="cover" />
598
+ ) : (
599
+ <Ionicons
600
+ name={isEliminated ? 'close-circle' : isMyItem ? 'star' : 'ellipse-outline'}
601
+ size={16}
602
+ color={isEliminated ? theme.colors.status.error : isMyItem ? theme.colors.primary.default : theme.colors.text.tertiary}
603
+ />
604
+ )}
605
+ <View variant="transparent" style={{ marginLeft: 8, flex: 1 }}>
606
+ <Text variant="body">{item.item_name}</Text>
607
+ {ownerName && (
608
+ <Text variant="caption" color="tertiary">
609
+ {isMyItem ? 'You' : ownerName}
610
+ </Text>
611
+ )}
612
+ {!item.winning_player_id && item.status !== 'unsold' && (
613
+ <Text variant="caption" color="tertiary">Unclaimed</Text>
614
+ )}
615
+ {item.status === 'unsold' && (
616
+ <Text variant="caption" color="tertiary">No owner</Text>
617
+ )}
618
+ </View>
619
+ {item.seed != null && <Text variant="caption" color="tertiary">#{item.seed}</Text>}
620
+ {isMyItem && <Text variant="caption" bold style={{ color: theme.colors.primary.default, marginLeft: 8 }}>YOU</Text>}
621
+ </View>
622
+ );
623
+ })}
624
+ </View>
625
+ )}
626
+
627
+ {!myItem && !isAdmin && (
628
+ <View variant="transparent" style={{ alignItems: 'center', paddingVertical: 20, paddingHorizontal: 16 }}>
629
+ <Ionicons name="time-outline" size={32} color={theme.colors.text.tertiary} />
630
+ <Text variant="body" color="tertiary" style={{ marginTop: 8 }}>
631
+ Waiting for the tournament to begin
632
+ </Text>
633
+ </View>
634
+ )}
635
+ </ScrollView>
636
+ </View>
637
+ );
638
+ };
639
+
337
640
  // Main exported component — routes to sealed bid or live layout
338
641
  export const CalcuttaAuction: React.FC<CalcuttaAuctionProps> = (props) => {
339
642
  const { theme } = useTheme();
@@ -358,6 +661,10 @@ export const CalcuttaAuction: React.FC<CalcuttaAuctionProps> = (props) => {
358
661
  );
359
662
  }
360
663
 
664
+ if (competition.auction_type === 'sweepstakes') {
665
+ return <SweepstakesWaiting {...props} competition={competition} />;
666
+ }
667
+
361
668
  if (competition.auction_type === 'sealed_bid') {
362
669
  return <SealedBidAuction {...props} />;
363
670
  }
@@ -19,7 +19,10 @@ export const CalcuttaCard: React.FC<CalcuttaCardProps> = ({
19
19
  const { theme } = useTheme();
20
20
 
21
21
  const statusLabel = getStatusLabel(competition.status);
22
- const auctionLabel = competition.auction_type === 'live' ? 'Live Auction' : 'Sealed Bid';
22
+ const auctionLabel = competition.auction_type === 'sweepstakes'
23
+ ? 'Sweepstakes'
24
+ : competition.auction_type === 'live' ? 'Live Auction' : 'Sealed Bid';
25
+ const isSweepstakes = competition.auction_type === 'sweepstakes';
23
26
  const participantCount = competition.participants?.length ?? 0;
24
27
 
25
28
  return (
@@ -57,9 +60,15 @@ export const CalcuttaCard: React.FC<CalcuttaCardProps> = ({
57
60
  </Text>
58
61
  </View>
59
62
  <View variant="transparent" style={styles.meta}>
60
- <Text variant="caption" color="secondary">
61
- Pot: {formatCurrency(competition.total_pot, competition.market_type)}
62
- </Text>
63
+ {isSweepstakes ? (
64
+ <Text variant="caption" color="secondary">
65
+ {competition.rounds?.filter(r => r.prize_description).length ?? 0} Prizes
66
+ </Text>
67
+ ) : (
68
+ <Text variant="caption" color="secondary">
69
+ Pot: {formatCurrency(competition.total_pot, competition.market_type)}
70
+ </Text>
71
+ )}
63
72
  <Text variant="caption" color="tertiary"> {'\u00B7'} </Text>
64
73
  <Text variant="caption" color="secondary">
65
74
  {participantCount} participant{participantCount !== 1 ? 's' : ''}
@@ -1,11 +1,13 @@
1
- import React, { useState, useMemo } from 'react';
2
- import { StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Image, FlatList } from 'react-native';
1
+ import React, { useState, useMemo, useCallback } from 'react';
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';
5
5
  import type { CalcuttaParticipantProps } from '@bettoredge/types';
6
6
  import { useCalcuttaCompetition } from '../hooks/useCalcuttaCompetition';
7
7
  import { useCalcuttaPlayers } from '../hooks/useCalcuttaPlayers';
8
- import { formatCurrency, getStatusLabel } from '../helpers/formatting';
8
+ import { useCalcuttaItemImages } from '../hooks/useCalcuttaItemImages';
9
+ import { formatCurrency, getStatusLabel, resolveItemImageUrl } from '../helpers/formatting';
10
+ import { startSweepstakesCompetition } from '@bettoredge/api';
9
11
 
10
12
  export interface CalcuttaDetailProps {
11
13
  calcutta_competition_id: string;
@@ -50,9 +52,26 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
50
52
 
51
53
  const participantIds = useMemo(() => participants.map(p => p.player_id), [participants]);
52
54
  const { players: enrichedPlayers } = useCalcuttaPlayers(participantIds);
55
+ const { images: itemImages } = useCalcuttaItemImages(items);
53
56
 
54
57
  const [joining, setJoining] = useState(false);
55
58
  const [leaving, setLeaving] = useState(false);
59
+ const [startingComp, setStartingComp] = useState(false);
60
+ const [itemSearch, setItemSearch] = useState('');
61
+
62
+ const filteredItems = useMemo(() => {
63
+ if (competition?.auction_type !== 'sweepstakes' || !itemSearch.trim()) return items;
64
+ const q = itemSearch.toLowerCase().trim();
65
+ return items.filter(item => {
66
+ if (item.item_name.toLowerCase().includes(q)) return true;
67
+ if (item.winning_player_id) {
68
+ const owner = enrichedPlayers[item.winning_player_id];
69
+ const ownerName = (owner?.username || owner?.show_name || '').toLowerCase();
70
+ if (ownerName.includes(q)) return true;
71
+ }
72
+ return false;
73
+ });
74
+ }, [items, itemSearch, enrichedPlayers, competition?.auction_type]);
56
75
 
57
76
  if (loading && !competition) {
58
77
  return (
@@ -75,11 +94,23 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
75
94
  const hasJoined = participants.some(p => p.player_id == player_id);
76
95
  const entryFee = Number(competition.entry_fee) || 0;
77
96
  const isFree = entryFee === 0;
78
- const isJoinable = ['scheduled', 'auction_open'].includes(competition.status);
97
+ const isSweepstakes = competition.auction_type === 'sweepstakes';
98
+ const isJoinable = isSweepstakes
99
+ ? competition.status === 'scheduled'
100
+ : ['scheduled', 'auction_open'].includes(competition.status);
79
101
  const totalPot = Number(competition.total_pot) || 0;
80
- const canLeave = hasJoined && ['pending', 'scheduled'].includes(competition.status);
102
+ const canLeave = hasJoined && !isSweepstakes && ['pending', 'scheduled'].includes(competition.status);
81
103
  const heroImage = competition.image?.url;
82
104
 
105
+ // Sweepstakes: find the player's assigned item
106
+ const myParticipant = participants.find(p => p.player_id == player_id);
107
+ const myAssignedItem = isSweepstakes && hasJoined
108
+ ? items.find(i => i.winning_player_id == player_id)
109
+ : undefined;
110
+ const unassignedItemCount = isSweepstakes
111
+ ? items.filter(i => !i.winning_player_id && i.status === 'pending').length
112
+ : 0;
113
+
83
114
  const sections = [
84
115
  { key: 'hero' },
85
116
  { key: 'info' },
@@ -112,7 +143,7 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
112
143
  <Text variant="caption" bold style={{ color: statusConfig.color }}>{statusConfig.label}</Text>
113
144
  </View>
114
145
  <Text variant="caption" color="tertiary" style={{ marginLeft: 8 }}>
115
- {competition.auction_type === 'live' ? 'Live Auction' : 'Sealed Bid'}
146
+ {isSweepstakes ? 'Sweepstakes' : competition.auction_type === 'live' ? 'Live Auction' : 'Sealed Bid'}
116
147
  </Text>
117
148
  </View>
118
149
  <Text variant="h3" bold>{competition.competition_name}</Text>
@@ -135,17 +166,24 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
135
166
  <Text variant="caption" color="tertiary">Entry Fee</Text>
136
167
  <Text variant="body" bold>{isFree ? 'FREE' : formatCurrency(entryFee, competition.market_type)}</Text>
137
168
  </View>
138
- <View variant="transparent" style={[styles.statBox, { borderLeftWidth: 1, borderColor: theme.colors.border.subtle }]}>
139
- <Text variant="caption" color="tertiary">Pot</Text>
140
- <Text variant="body" bold>{formatCurrency(totalPot, competition.market_type)}</Text>
141
- </View>
169
+ {isSweepstakes ? (
170
+ <View variant="transparent" style={[styles.statBox, { borderLeftWidth: 1, borderColor: theme.colors.border.subtle }]}>
171
+ <Text variant="caption" color="tertiary">Available</Text>
172
+ <Text variant="body" bold>{unassignedItemCount}/{items.length}</Text>
173
+ </View>
174
+ ) : (
175
+ <View variant="transparent" style={[styles.statBox, { borderLeftWidth: 1, borderColor: theme.colors.border.subtle }]}>
176
+ <Text variant="caption" color="tertiary">Pot</Text>
177
+ <Text variant="body" bold>{formatCurrency(totalPot, competition.market_type)}</Text>
178
+ </View>
179
+ )}
142
180
  <View variant="transparent" style={[styles.statBox, { borderLeftWidth: 1, borderColor: theme.colors.border.subtle }]}>
143
181
  <Text variant="caption" color="tertiary">Players</Text>
144
182
  <Text variant="body" bold>{participants.length}{competition.max_participants > 0 ? `/${competition.max_participants}` : ''}</Text>
145
183
  </View>
146
184
  <View variant="transparent" style={[styles.statBox, { borderLeftWidth: 1, borderColor: theme.colors.border.subtle }]}>
147
- <Text variant="caption" color="tertiary">Items</Text>
148
- <Text variant="body" bold>{items.length}</Text>
185
+ <Text variant="caption" color="tertiary">{isSweepstakes ? 'Prizes' : 'Items'}</Text>
186
+ <Text variant="body" bold>{isSweepstakes ? rounds.filter(r => r.prize_description).length : items.length}</Text>
149
187
  </View>
150
188
  </View>
151
189
  );
@@ -161,6 +199,33 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
161
199
  </TouchableOpacity>
162
200
  )}
163
201
 
202
+ {/* Admin start sweepstakes button */}
203
+ {isAdmin && isSweepstakes && competition.status === 'scheduled' && (
204
+ <TouchableOpacity
205
+ onPress={async () => {
206
+ setStartingComp(true);
207
+ try {
208
+ await startSweepstakesCompetition(calcutta_competition_id);
209
+ 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
+
164
229
  {/* Join button */}
165
230
  {isJoinable && !hasJoined && onJoin && (
166
231
  <TouchableOpacity
@@ -179,38 +244,65 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
179
244
  <ActivityIndicator size="small" color="#FFF" />
180
245
  ) : (
181
246
  <Text variant="body" bold style={{ color: '#FFF' }}>
182
- {isFree ? 'Join - Free!' : `Join - ${formatCurrency(entryFee, competition.market_type)}`}
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)}`)}
183
250
  </Text>
184
251
  )}
185
252
  </TouchableOpacity>
186
253
  )}
187
254
 
188
- {/* Already joined badge + leave */}
255
+ {/* Already joined badge + assigned item (sweepstakes) + leave */}
189
256
  {hasJoined && (
190
- <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
191
- <View variant="transparent" style={[styles.joinedBadge, { flex: 1, backgroundColor: '#10B98115' }]}>
192
- <Ionicons name="checkmark-circle" size={16} color="#10B981" />
193
- <Text variant="caption" bold style={{ color: '#10B981', marginLeft: 6 }}>You're in!</Text>
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
+ )}
194
283
  </View>
195
- {onLeave && canLeave && (
196
- <TouchableOpacity
197
- onPress={async () => {
198
- setLeaving(true);
199
- try {
200
- await onLeave();
201
- await refresh();
202
- } catch {}
203
- setLeaving(false);
204
- }}
205
- disabled={leaving}
206
- style={[styles.ctaButtonOutline, { borderColor: theme.colors.status.error, paddingHorizontal: 16, opacity: leaving ? 0.7 : 1 }]}
207
- >
208
- {leaving ? (
209
- <ActivityIndicator size="small" color={theme.colors.status.error} />
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
+ />
210
293
  ) : (
211
- <Text variant="caption" bold style={{ color: theme.colors.status.error }}>Leave</Text>
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>
212
297
  )}
213
- </TouchableOpacity>
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>
305
+ </View>
214
306
  )}
215
307
  </View>
216
308
  )}
@@ -226,6 +318,32 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
226
318
  );
227
319
 
228
320
  case 'payouts':
321
+ // Sweepstakes: show prize list per round instead of payout percentages
322
+ if (isSweepstakes) {
323
+ const prizeRounds = rounds.filter(r => r.prize_description);
324
+ if (prizeRounds.length === 0) return null;
325
+ return (
326
+ <View variant="transparent" style={[styles.section, { borderColor: theme.colors.border.subtle }]}>
327
+ <Text variant="body" bold style={styles.sectionTitle}>Prizes</Text>
328
+ {prizeRounds.map((round, i) => (
329
+ <View key={round.calcutta_round_id} variant="transparent" style={[styles.payoutRow, i < prizeRounds.length - 1 && { borderBottomWidth: 1, borderColor: theme.colors.border.subtle }]}>
330
+ {round.prize_image?.url ? (
331
+ <Image
332
+ source={{ uri: round.prize_image.url }}
333
+ style={{ width: 40, height: 40, borderRadius: 6, marginRight: 10 }}
334
+ resizeMode="cover"
335
+ />
336
+ ) : null}
337
+ <View variant="transparent" style={{ flex: 1 }}>
338
+ <Text variant="body" bold>{round.round_name}</Text>
339
+ <Text variant="caption" color="secondary" style={{ marginTop: 2 }}>{round.prize_description}</Text>
340
+ </View>
341
+ </View>
342
+ ))}
343
+ </View>
344
+ );
345
+ }
346
+
229
347
  if (payout_rules.length === 0) return null;
230
348
  return (
231
349
  <View variant="transparent" style={[styles.section, { borderColor: theme.colors.border.subtle }]}>
@@ -255,12 +373,69 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
255
373
  if (items.length === 0) return null;
256
374
  return (
257
375
  <View variant="transparent" style={[styles.section, { borderColor: theme.colors.border.subtle, paddingBottom: 0 }]}>
258
- <Text variant="body" bold style={styles.sectionTitle}>Auction Items ({items.length})</Text>
376
+ <Text variant="body" bold style={styles.sectionTitle}>{isSweepstakes ? 'Teams' : 'Auction Items'} ({items.length})</Text>
259
377
  </View>
260
378
  );
261
379
 
262
380
  case 'items':
263
381
  if (items.length === 0) return null;
382
+ if (isSweepstakes) {
383
+ return (
384
+ <View variant="transparent" style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
385
+ <TextInput
386
+ style={{
387
+ height: 36,
388
+ borderRadius: 8,
389
+ borderWidth: 1,
390
+ borderColor: theme.colors.border.subtle,
391
+ backgroundColor: theme.colors.surface.input,
392
+ color: theme.colors.text.primary,
393
+ paddingHorizontal: 12,
394
+ fontSize: 14,
395
+ marginBottom: 8,
396
+ }}
397
+ placeholder="Search by team or owner..."
398
+ placeholderTextColor={theme.colors.text.tertiary}
399
+ value={itemSearch}
400
+ onChangeText={setItemSearch}
401
+ autoCapitalize="none"
402
+ autoCorrect={false}
403
+ onFocus={(e: any) => {
404
+ if (Platform.OS === 'web' && e?.target?.scrollIntoView) {
405
+ setTimeout(() => e.target.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300);
406
+ }
407
+ }}
408
+ />
409
+ {filteredItems.map(item => {
410
+ const imgUrl = resolveItemImageUrl(item.item_image) || itemImages[item.item_id]?.url;
411
+ const owner = item.winning_player_id ? enrichedPlayers[item.winning_player_id] : undefined;
412
+ const ownerName = owner?.username || owner?.show_name;
413
+ const isMe = item.winning_player_id == player_id;
414
+ return (
415
+ <View key={item.calcutta_auction_item_id} variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderBottomWidth: 1, borderColor: theme.colors.border.subtle }}>
416
+ {imgUrl ? (
417
+ <Image source={{ uri: imgUrl }} style={{ width: 28, height: 28, borderRadius: 6 }} resizeMode="cover" />
418
+ ) : (
419
+ <View variant="transparent" style={{ width: 28, height: 28, borderRadius: 6, backgroundColor: theme.colors.surface.elevated, alignItems: 'center', justifyContent: 'center' }}>
420
+ <Ionicons name="trophy-outline" size={14} color={theme.colors.text.tertiary} />
421
+ </View>
422
+ )}
423
+ <View variant="transparent" style={{ marginLeft: 8, flex: 1 }}>
424
+ <Text variant="body">{item.item_name}</Text>
425
+ {ownerName ? (
426
+ <Text variant="caption" color="tertiary">{isMe ? 'You' : ownerName}</Text>
427
+ ) : (
428
+ <Text variant="caption" color="tertiary">Available</Text>
429
+ )}
430
+ </View>
431
+ {item.seed != null && <Text variant="caption" color="tertiary">#{item.seed}</Text>}
432
+ {isMe && <Text variant="caption" bold style={{ color: theme.colors.primary.default, marginLeft: 8 }}>YOU</Text>}
433
+ </View>
434
+ );
435
+ })}
436
+ </View>
437
+ );
438
+ }
264
439
  return (
265
440
  <View variant="transparent" style={{ paddingHorizontal: 16 }}>
266
441
  <ScrollView horizontal showsHorizontalScrollIndicator={false}>
@@ -332,20 +507,25 @@ export const CalcuttaDetail: React.FC<CalcuttaDetailProps> = ({
332
507
  };
333
508
 
334
509
  return (
335
- <View variant="transparent" style={styles.container}>
336
- {/* Header bar */}
337
- <View variant="transparent" style={[styles.headerBar, { borderColor: theme.colors.border.subtle }]}>
338
- <TouchableOpacity onPress={onClose} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
339
- <Ionicons name="arrow-back" size={24} color={theme.colors.text.primary} />
340
- </TouchableOpacity>
341
- <Text variant="body" bold style={{ flex: 1, marginLeft: 12 }} numberOfLines={1}>{competition.competition_name}</Text>
342
- </View>
510
+ <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
+ )}
343
520
 
344
521
  <FlatList
345
522
  data={sections}
346
523
  keyExtractor={(item) => item.key}
347
524
  renderItem={renderSection}
348
525
  showsVerticalScrollIndicator={false}
526
+ style={{ backgroundColor: theme.colors.surface.base }}
527
+ contentContainerStyle={{ paddingBottom: Platform.OS === 'web' ? 300 : 40 }}
528
+ keyboardShouldPersistTaps="handled"
349
529
  />
350
530
  </View>
351
531
  );
@@ -374,4 +554,6 @@ const styles = StyleSheet.create({
374
554
  participantRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10 },
375
555
  avatarCircle: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', marginRight: 10 },
376
556
  youBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 8 },
557
+ assignedItemCard: { flexDirection: 'row', alignItems: 'center', padding: 12, borderRadius: 10, borderWidth: 1 },
558
+ assignedItemImage: { width: 44, height: 44, borderRadius: 8 },
377
559
  });
@@ -1,8 +1,8 @@
1
1
  import React from 'react';
2
- import { StyleSheet, FlatList } from 'react-native';
2
+ import { StyleSheet, FlatList, Image } from 'react-native';
3
3
  import { View, Text, useTheme } from '@bettoredge/styles';
4
4
  import { Ionicons } from '@expo/vector-icons';
5
- import type { CalcuttaPayoutRuleProps } from '@bettoredge/types';
5
+ import type { CalcuttaPayoutRuleProps, CalcuttaRoundProps } from '@bettoredge/types';
6
6
  import { formatCurrency } from '../helpers/formatting';
7
7
 
8
8
  export interface CalcuttaPayoutPreviewProps {
@@ -11,6 +11,8 @@ export interface CalcuttaPayoutPreviewProps {
11
11
  market_type: string;
12
12
  unclaimed_pot?: number;
13
13
  min_spend_pct?: number;
14
+ auction_type?: string;
15
+ rounds?: CalcuttaRoundProps[];
14
16
  }
15
17
 
16
18
  export const CalcuttaPayoutPreview: React.FC<CalcuttaPayoutPreviewProps> = ({
@@ -19,8 +21,58 @@ export const CalcuttaPayoutPreview: React.FC<CalcuttaPayoutPreviewProps> = ({
19
21
  market_type,
20
22
  unclaimed_pot,
21
23
  min_spend_pct,
24
+ auction_type,
25
+ rounds,
22
26
  }) => {
23
27
  const { theme } = useTheme();
28
+ const isSweepstakes = auction_type === 'sweepstakes';
29
+
30
+ // Sweepstakes: render prize list per round instead of percentages
31
+ if (isSweepstakes && rounds) {
32
+ const prizeRounds = rounds.filter(r => r.prize_description);
33
+ if (prizeRounds.length === 0) {
34
+ return (
35
+ <View variant="transparent" style={styles.container}>
36
+ <Text variant="caption" color="tertiary" style={styles.emptyText}>
37
+ No prizes defined
38
+ </Text>
39
+ </View>
40
+ );
41
+ }
42
+ return (
43
+ <View variant="transparent" style={styles.container}>
44
+ <View
45
+ variant="transparent"
46
+ style={[styles.potSummary, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle }]}
47
+ >
48
+ <Ionicons name="gift-outline" size={20} color={theme.colors.primary.default} />
49
+ <View variant="transparent" style={styles.potInfo}>
50
+ <Text variant="caption" color="tertiary">Prizes</Text>
51
+ <Text variant="body" bold>{prizeRounds.length} round{prizeRounds.length !== 1 ? 's' : ''}</Text>
52
+ </View>
53
+ </View>
54
+ {prizeRounds.map(round => (
55
+ <View
56
+ key={round.calcutta_round_id}
57
+ variant="transparent"
58
+ style={[styles.ruleRow, { borderColor: theme.colors.border.subtle }]}
59
+ >
60
+ {round.prize_image?.url && (
61
+ <Image
62
+ source={{ uri: round.prize_image.url }}
63
+ style={{ width: 36, height: 36, borderRadius: 6, marginRight: 10 }}
64
+ resizeMode="cover"
65
+ />
66
+ )}
67
+ <View variant="transparent" style={styles.ruleInfo}>
68
+ <Text variant="body" numberOfLines={1}>{round.round_name}</Text>
69
+ <Text variant="caption" color="secondary">{round.prize_description}</Text>
70
+ </View>
71
+ </View>
72
+ ))}
73
+ </View>
74
+ );
75
+ }
24
76
 
25
77
  const roundRules = payout_rules
26
78
  .filter(r => r.payout_type === 'round')
@@ -19,6 +19,7 @@ export interface CalcuttaRoundResultsProps {
19
19
  market_type: string;
20
20
  total_pot?: number;
21
21
  unclaimed_pot?: number;
22
+ auction_type?: string;
22
23
  }
23
24
 
24
25
  export const CalcuttaRoundResults: React.FC<CalcuttaRoundResultsProps> = ({
@@ -29,7 +30,9 @@ export const CalcuttaRoundResults: React.FC<CalcuttaRoundResultsProps> = ({
29
30
  market_type,
30
31
  total_pot,
31
32
  unclaimed_pot,
33
+ auction_type,
32
34
  }) => {
35
+ const isSweepstakes = auction_type === 'sweepstakes';
33
36
  const { theme } = useTheme();
34
37
  const { roundSummaries } = useCalcuttaResults(rounds, items, item_results, payout_rules);
35
38
  const [expandedRound, setExpandedRound] = useState<string | null>(null);
@@ -72,11 +75,16 @@ export const CalcuttaRoundResults: React.FC<CalcuttaRoundResultsProps> = ({
72
75
  <Text variant="caption" color="tertiary">
73
76
  {getStatusLabel(summary.round.status)}
74
77
  </Text>
75
- {summary.total_payout > 0 && (
78
+ {!isSweepstakes && summary.total_payout > 0 && (
76
79
  <Text variant="caption" style={{ color: theme.colors.status.success, marginLeft: 8 }}>
77
80
  {formatCurrency(summary.total_payout, market_type)} paid
78
81
  </Text>
79
82
  )}
83
+ {isSweepstakes && summary.round.prize_description && (
84
+ <Text variant="caption" style={{ color: theme.colors.primary.default, marginLeft: 8 }} numberOfLines={1}>
85
+ Prize available
86
+ </Text>
87
+ )}
80
88
  </View>
81
89
  </View>
82
90
  <View variant="transparent" style={styles.roundCounts}>
@@ -95,8 +103,18 @@ export const CalcuttaRoundResults: React.FC<CalcuttaRoundResultsProps> = ({
95
103
 
96
104
  {isExpanded && (
97
105
  <View variant="transparent" style={[styles.roundBody, { borderColor: theme.colors.border.subtle }]}>
98
- {/* Payout rule */}
99
- {summary.payout_rule && (
106
+ {/* Sweepstakes: show prize instead of payout rule */}
107
+ {isSweepstakes && summary.round.prize_description && (
108
+ <View variant="transparent" style={styles.payoutRow}>
109
+ <Ionicons name="gift-outline" size={14} color={theme.colors.primary.default} />
110
+ <Text variant="caption" style={{ marginLeft: 6, color: theme.colors.primary.default, flex: 1 }}>
111
+ Prize: {summary.round.prize_description}
112
+ </Text>
113
+ </View>
114
+ )}
115
+
116
+ {/* Payout rule (non-sweepstakes) */}
117
+ {!isSweepstakes && summary.payout_rule && (
100
118
  <View variant="transparent" style={styles.payoutRow}>
101
119
  <Ionicons name="cash-outline" size={14} color={theme.colors.text.tertiary} />
102
120
  <Text variant="caption" color="tertiary" style={{ marginLeft: 6 }}>
@@ -105,8 +123,8 @@ export const CalcuttaRoundResults: React.FC<CalcuttaRoundResultsProps> = ({
105
123
  </View>
106
124
  )}
107
125
 
108
- {/* Rollover notice for unsold advancing items */}
109
- {summary.round.status === 'closed' && summary.advanced_items.some(i => !i.winning_player_id) && (
126
+ {/* Rollover notice for unsold advancing items (non-sweepstakes) */}
127
+ {!isSweepstakes && summary.round.status === 'closed' && summary.advanced_items.some(i => !i.winning_player_id) && (
110
128
  <View variant="transparent" style={[styles.payoutRow, { marginTop: 2 }]}>
111
129
  <Ionicons name="arrow-forward-circle-outline" size={14} color={theme.colors.primary.default} />
112
130
  <Text variant="caption" style={{ marginLeft: 6, color: theme.colors.primary.default, flex: 1 }}>
@@ -0,0 +1,178 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { StyleSheet, Modal, TouchableOpacity, Image, Animated } from 'react-native';
3
+ import { View, Text, useTheme } from '@bettoredge/styles';
4
+ import { Ionicons } from '@expo/vector-icons';
5
+ import type { CalcuttaAuctionItemProps } from '@bettoredge/types';
6
+ import { resolveItemImageUrl } from '../helpers/formatting';
7
+
8
+ export interface SweepstakesRevealProps {
9
+ visible: boolean;
10
+ item?: CalcuttaAuctionItemProps;
11
+ /** Resolved image URL from sr_svc (getBulkTeams/getBulkAthletes) */
12
+ itemImageUrl?: string;
13
+ competitionName?: string;
14
+ onClose: () => void;
15
+ }
16
+
17
+ export const SweepstakesReveal: React.FC<SweepstakesRevealProps> = ({
18
+ visible,
19
+ item,
20
+ itemImageUrl,
21
+ competitionName,
22
+ onClose,
23
+ }) => {
24
+ const { theme } = useTheme();
25
+ const scaleAnim = useRef(new Animated.Value(0.3)).current;
26
+ const opacityAnim = useRef(new Animated.Value(0)).current;
27
+
28
+ useEffect(() => {
29
+ if (visible && item) {
30
+ scaleAnim.setValue(0.3);
31
+ opacityAnim.setValue(0);
32
+ Animated.parallel([
33
+ Animated.spring(scaleAnim, {
34
+ toValue: 1,
35
+ tension: 50,
36
+ friction: 7,
37
+ useNativeDriver: true,
38
+ }),
39
+ Animated.timing(opacityAnim, {
40
+ toValue: 1,
41
+ duration: 300,
42
+ useNativeDriver: true,
43
+ }),
44
+ ]).start();
45
+ }
46
+ }, [visible, item]);
47
+
48
+ if (!item) return null;
49
+
50
+ return (
51
+ <Modal
52
+ visible={visible}
53
+ transparent
54
+ animationType="fade"
55
+ onRequestClose={onClose}
56
+ >
57
+ <View variant="transparent" style={styles.backdrop}>
58
+ <Animated.View style={[
59
+ styles.card,
60
+ {
61
+ backgroundColor: theme.colors.surface.elevated,
62
+ borderColor: theme.colors.border.subtle,
63
+ transform: [{ scale: scaleAnim }],
64
+ opacity: opacityAnim,
65
+ },
66
+ ]}>
67
+ {/* Confetti-style header */}
68
+ <View variant="transparent" style={[styles.celebrationHeader, { backgroundColor: '#8B5CF6' }]}>
69
+ <Text style={styles.emoji}>🎉</Text>
70
+ <Text variant="h3" bold style={{ color: '#FFF', textAlign: 'center' }}>
71
+ You Got a Team!
72
+ </Text>
73
+ {competitionName && (
74
+ <Text variant="caption" style={{ color: '#FFFFFFCC', marginTop: 4 }}>
75
+ {competitionName}
76
+ </Text>
77
+ )}
78
+ </View>
79
+
80
+ {/* Item display */}
81
+ <View variant="transparent" style={styles.itemSection}>
82
+ {(itemImageUrl || resolveItemImageUrl(item.item_image)) ? (
83
+ <Image
84
+ source={{ uri: (itemImageUrl || resolveItemImageUrl(item.item_image))! }}
85
+ style={styles.itemImage}
86
+ resizeMode="cover"
87
+ />
88
+ ) : (
89
+ <View variant="transparent" style={[styles.itemImage, styles.itemImageFallback, { backgroundColor: theme.colors.primary.subtle }]}>
90
+ <Ionicons name="trophy" size={48} color={theme.colors.primary.default} />
91
+ </View>
92
+ )}
93
+
94
+ <Text variant="h2" bold style={{ marginTop: 16, textAlign: 'center' }}>
95
+ {item.item_name}
96
+ </Text>
97
+
98
+ {item.seed != null && (
99
+ <View variant="transparent" style={[styles.seedBadge, { backgroundColor: theme.colors.primary.subtle }]}>
100
+ <Text variant="body" bold style={{ color: theme.colors.primary.default }}>
101
+ Seed #{item.seed}
102
+ </Text>
103
+ </View>
104
+ )}
105
+
106
+ <Text variant="body" color="secondary" style={{ marginTop: 12, textAlign: 'center' }}>
107
+ Cheer them on as they compete through each round!
108
+ </Text>
109
+ </View>
110
+
111
+ {/* Close button */}
112
+ <TouchableOpacity
113
+ style={[styles.closeButton, { backgroundColor: '#8B5CF6' }]}
114
+ onPress={onClose}
115
+ activeOpacity={0.8}
116
+ >
117
+ <Text variant="body" bold style={{ color: '#FFF' }}>Let's Go!</Text>
118
+ </TouchableOpacity>
119
+ </Animated.View>
120
+ </View>
121
+ </Modal>
122
+ );
123
+ };
124
+
125
+ const styles = StyleSheet.create({
126
+ backdrop: {
127
+ flex: 1,
128
+ backgroundColor: 'rgba(0,0,0,0.6)',
129
+ justifyContent: 'center',
130
+ alignItems: 'center',
131
+ padding: 24,
132
+ },
133
+ card: {
134
+ width: '100%',
135
+ maxWidth: 360,
136
+ borderRadius: 16,
137
+ borderWidth: 1,
138
+ },
139
+ celebrationHeader: {
140
+ paddingTop: 32,
141
+ paddingBottom: 24,
142
+ paddingHorizontal: 20,
143
+ alignItems: 'center',
144
+ borderTopLeftRadius: 15,
145
+ borderTopRightRadius: 15,
146
+ },
147
+ emoji: {
148
+ fontSize: 48,
149
+ marginBottom: 8,
150
+ lineHeight: 56,
151
+ },
152
+ itemSection: {
153
+ padding: 24,
154
+ alignItems: 'center',
155
+ },
156
+ itemImage: {
157
+ width: 120,
158
+ height: 120,
159
+ borderRadius: 16,
160
+ },
161
+ itemImageFallback: {
162
+ alignItems: 'center',
163
+ justifyContent: 'center',
164
+ },
165
+ seedBadge: {
166
+ paddingHorizontal: 16,
167
+ paddingVertical: 6,
168
+ borderRadius: 20,
169
+ marginTop: 10,
170
+ },
171
+ closeButton: {
172
+ marginHorizontal: 24,
173
+ marginBottom: 24,
174
+ paddingVertical: 14,
175
+ borderRadius: 10,
176
+ alignItems: 'center',
177
+ },
178
+ });
@@ -45,6 +45,15 @@ export const getItemStatusColor = (status: string): string => {
45
45
  }
46
46
  };
47
47
 
48
+ /** Unwrap possibly double-wrapped image: { url: string } or { url: { url, secure_url, ... } } */
49
+ export const resolveItemImageUrl = (item_image?: { url: any }): string | undefined => {
50
+ if (!item_image) return undefined;
51
+ const raw = item_image.url;
52
+ if (typeof raw === 'string') return raw;
53
+ if (raw && typeof raw === 'object') return raw.secure_url || raw.url;
54
+ return undefined;
55
+ };
56
+
48
57
  export const getBidStatusColor = (status: string): string => {
49
58
  switch (status) {
50
59
  case 'active': return '#3B82F6';
@@ -15,19 +15,25 @@ export type CalcuttaLifecycleState =
15
15
  /**
16
16
  * Derive the lifecycle state from competition.status and competition.auction_status.
17
17
  *
18
- * CRITICAL: auction_status === 'closed' is the hard gate. If the server says
19
- * the auction is closed we NEVER return 'auctioning', no matter what `status` says.
18
+ * Sweepstakes competitions skip the auctioning phase entirely:
19
+ * pending scheduled tournament completed
20
20
  *
21
- * NOTE: We intentionally do NOT accept a client-side timer override here.
22
- * When the countdown expires but the server hasn't confirmed, the lifecycle
23
- * stays 'auctioning' and the orchestrator uses a separate `auctionExpired`
24
- * flag to disable all bid actions. This prevents showing empty results
25
- * before the server has resolved winners.
21
+ * @param auction_type - Optional. When 'sweepstakes', the auctioning state is never returned.
26
22
  */
27
23
  export function deriveLifecycleState(
28
24
  status?: string,
29
25
  auction_status?: string,
26
+ auction_type?: string,
30
27
  ): CalcuttaLifecycleState {
28
+ // ── Sweepstakes: no auction phase ──
29
+ if (auction_type === 'sweepstakes') {
30
+ if (!status || status === 'pending') return 'pending';
31
+ if (status === 'scheduled') return 'scheduled';
32
+ if (status === 'closed') return 'completed';
33
+ // auction_closed, inprogress → tournament
34
+ return 'tournament';
35
+ }
36
+
31
37
  // ── Hard gate: server confirmed auction closed ──
32
38
  if (auction_status === 'closed') {
33
39
  return status === 'closed' ? 'completed' : 'tournament';
@@ -59,12 +65,12 @@ export function deriveLifecycleState(
59
65
  }
60
66
 
61
67
  /** True when users can place / update / cancel bids */
62
- export const canBid = (state: CalcuttaLifecycleState): boolean =>
63
- state === 'auctioning';
68
+ export const canBid = (state: CalcuttaLifecycleState, auction_type?: string): boolean =>
69
+ auction_type === 'sweepstakes' ? false : state === 'auctioning';
64
70
 
65
71
  /** True when escrow deposit / withdraw controls should be interactive */
66
- export const canManageEscrow = (state: CalcuttaLifecycleState): boolean =>
67
- state === 'scheduled' || state === 'auctioning';
72
+ export const canManageEscrow = (state: CalcuttaLifecycleState, auction_type?: string): boolean =>
73
+ auction_type === 'sweepstakes' ? false : (state === 'scheduled' || state === 'auctioning');
68
74
 
69
75
  /** True when round results / leaderboard data is meaningful */
70
76
  export const showResults = (state: CalcuttaLifecycleState): boolean =>
@@ -12,6 +12,10 @@ export interface RoundResultSummary {
12
12
  eliminated_items: CalcuttaAuctionItemProps[];
13
13
  payout_rule?: CalcuttaPayoutRuleProps;
14
14
  total_payout: number;
15
+ /** Prize description for this round (sweepstakes only) */
16
+ prize_description?: string;
17
+ /** Prize image for this round (sweepstakes only) */
18
+ prize_image?: { url: string };
15
19
  }
16
20
 
17
21
  export const useCalcuttaResults = (
@@ -35,6 +39,8 @@ export const useCalcuttaResults = (
35
39
  eliminated_items: items.filter(i => eliminatedIds.includes(i.calcutta_auction_item_id)),
36
40
  payout_rule: rule,
37
41
  total_payout: totalPayout,
42
+ prize_description: round.prize_description,
43
+ prize_image: round.prize_image,
38
44
  };
39
45
  });
40
46
  }, [rounds, items, item_results, payout_rules]);
package/src/index.ts CHANGED
@@ -26,6 +26,9 @@ export type { CalcuttaRoundResultsProps } from './components/CalcuttaRoundResult
26
26
  export { CalcuttaItemResults } from './components/CalcuttaItemResults';
27
27
  export type { CalcuttaItemResultsProps } from './components/CalcuttaItemResults';
28
28
 
29
+ export { SweepstakesReveal } from './components/SweepstakesReveal';
30
+ export type { SweepstakesRevealProps } from './components/SweepstakesReveal';
31
+
29
32
  export { CalcuttaTemplateSelector } from './components/CalcuttaTemplateSelector';
30
33
  export type { CalcuttaTemplateSelectorProps } from './components/CalcuttaTemplateSelector';
31
34
 
@@ -64,7 +67,7 @@ export { useCalcuttaItemImages } from './hooks/useCalcuttaItemImages';
64
67
  export type { ItemImageMap } from './hooks/useCalcuttaItemImages';
65
68
 
66
69
  // Helpers
67
- export { formatCurrency, formatPlace, getStatusLabel, getItemStatusColor, getBidStatusColor } from './helpers/formatting';
70
+ export { formatCurrency, formatPlace, getStatusLabel, getItemStatusColor, getBidStatusColor, resolveItemImageUrl } from './helpers/formatting';
68
71
  export { calculatePayoutPreview, validatePayoutRules } from './helpers/payout';
69
72
  export { validateBidAmount, validateEscrowDeposit, validateEscrowWithdraw } from './helpers/validation';
70
73
  export { deriveLifecycleState, canBid, canManageEscrow, showResults } from './helpers/lifecycleState';
package/src/types.ts CHANGED
@@ -27,5 +27,5 @@ export type {
27
27
  };
28
28
 
29
29
  export type CalcuttaStatus = 'pending' | 'scheduled' | 'auction_open' | 'auction_closed' | 'inprogress' | 'closed';
30
- export type AuctionType = 'sealed_bid' | 'live';
30
+ export type AuctionType = 'sealed_bid' | 'live' | 'sweepstakes';
31
31
  export type PayoutType = 'round' | 'placement' | 'both';