@bettoredge/calcutta 0.5.0 → 0.5.2

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.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Calcutta auction competition components for BettorEdge applications",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useMemo, useEffect } from 'react';
2
- import { StyleSheet, ScrollView, TouchableOpacity, Image, FlatList, Platform, useWindowDimensions, RefreshControl } from 'react-native';
2
+ import { StyleSheet, ScrollView, TouchableOpacity, Image, FlatList, Platform, useWindowDimensions, RefreshControl, TextInput, KeyboardAvoidingView } 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';
@@ -54,7 +54,7 @@ export const CalcuttaResults: React.FC<CalcuttaResultsProps> = ({
54
54
  const participantIds = useMemo(() => participants.map(p => p.player_id), [participants]);
55
55
  const { players: enrichedPlayers } = useCalcuttaPlayers(participantIds);
56
56
  const { images: itemImages } = useCalcuttaItemImages(items);
57
- const { roundSummaries } = useCalcuttaResults(rounds, items, item_results, payout_rules);
57
+ const { roundSummaries, itemPerformance } = useCalcuttaResults(rounds, items, item_results, payout_rules);
58
58
 
59
59
  const { presence, socketState } = useCalcuttaSocket(
60
60
  calcutta_competition_id,
@@ -79,16 +79,10 @@ export const CalcuttaResults: React.FC<CalcuttaResultsProps> = ({
79
79
  items.filter(i => i.status === 'sold').reduce((sum, i) => sum + Number(i.winning_bid || 0), 0) || Number(competition?.total_pot) || 0,
80
80
  [items, competition?.total_pot]
81
81
  );
82
- const myTotalSpent = useMemo(() => myItems.reduce((sum, i) => sum + Number(i.winning_bid || 0), 0), [myItems]);
83
- const myTotalEarned = useMemo(() => {
84
- if (!player_id) return 0;
85
- return item_results
86
- .filter(r => {
87
- const item = items.find(i => i.calcutta_auction_item_id === r.calcutta_auction_item_id);
88
- return item?.winning_player_id == player_id;
89
- })
90
- .reduce((sum, r) => sum + Number(r.payout_earned || 0), 0);
91
- }, [item_results, items, player_id]);
82
+ const unclaimedPot = Number(competition?.unclaimed_pot) || 0;
83
+ const myParticipant = useMemo(() => participants.find(p => p.player_id == player_id), [participants, player_id]);
84
+ const myTotalSpent = Number(myParticipant?.total_spent) || 0;
85
+ const myTotalEarned = Number(myParticipant?.total_winnings) || 0;
92
86
 
93
87
  const statusLabel = competition?.status === 'closed' ? 'Completed'
94
88
  : competition?.status === 'inprogress' ? 'In Progress'
@@ -108,23 +102,84 @@ export const CalcuttaResults: React.FC<CalcuttaResultsProps> = ({
108
102
  [participants]
109
103
  );
110
104
 
105
+ // Mobile tab
106
+ type BottomTab = 'rounds' | 'leaderboard' | 'items';
107
+ const [mobileTab, setMobileTab] = useState<BottomTab>('rounds');
108
+
111
109
  // Expandable rounds
112
110
  const [expandedRound, setExpandedRound] = useState<number | null>(null);
113
111
 
114
- // Item search
112
+ // Leaderboard search, pagination
113
+ const [leaderSearch, setLeaderSearch] = useState('');
114
+ const LEADERS_PER_PAGE = 20;
115
+ const [leaderPage, setLeaderPage] = useState(0);
116
+
117
+ const filteredLeaderboard = useMemo(() => {
118
+ if (!leaderSearch.trim()) return leaderboard;
119
+ const q = leaderSearch.toLowerCase();
120
+ return leaderboard.filter(p => {
121
+ const profile = enrichedPlayers[p.player_id];
122
+ const username = (profile?.username || profile?.show_name || '').toLowerCase();
123
+ if (username.includes(q)) return true;
124
+ if (p.player_id == player_id && 'you'.includes(q)) return true;
125
+ return false;
126
+ });
127
+ }, [leaderboard, leaderSearch, enrichedPlayers, player_id]);
128
+
129
+ const pagedLeaderboard = useMemo(() => {
130
+ const start = leaderPage * LEADERS_PER_PAGE;
131
+ return filteredLeaderboard.slice(start, start + LEADERS_PER_PAGE);
132
+ }, [filteredLeaderboard, leaderPage]);
133
+
134
+ const totalLeaderPages = Math.ceil(filteredLeaderboard.length / LEADERS_PER_PAGE);
135
+
136
+ // Item search, sort, pagination
115
137
  const [itemSearch, setItemSearch] = useState('');
138
+ type ItemSort = 'name' | 'paid' | 'bid';
139
+ const [itemSort, setItemSort] = useState<ItemSort>('paid');
140
+ const ITEMS_PER_PAGE = 20;
141
+ const [itemPage, setItemPage] = useState(0);
142
+
143
+ const itemEarningsMap = useMemo(() => {
144
+ const map: Record<string, number> = {};
145
+ for (const ip of itemPerformance) {
146
+ map[ip.item.calcutta_auction_item_id] = ip.total_earned;
147
+ }
148
+ return map;
149
+ }, [itemPerformance]);
150
+
116
151
  const filteredItems = useMemo(() => {
117
- if (!itemSearch.trim()) return items;
118
- const q = itemSearch.toLowerCase();
119
- return items.filter(i => {
120
- if (i.item_name.toLowerCase().includes(q)) return true;
121
- if (i.winning_player_id) {
122
- const owner = enrichedPlayers[i.winning_player_id];
123
- if ((owner?.username || '').toLowerCase().includes(q)) return true;
152
+ let result = items;
153
+ if (itemSearch.trim()) {
154
+ const q = itemSearch.toLowerCase();
155
+ result = result.filter(i => {
156
+ if (i.item_name.toLowerCase().includes(q)) return true;
157
+ if (i.winning_player_id) {
158
+ const owner = enrichedPlayers[i.winning_player_id];
159
+ if ((owner?.username || '').toLowerCase().includes(q)) return true;
160
+ }
161
+ return false;
162
+ });
163
+ }
164
+ return [...result].sort((a, b) => {
165
+ switch (itemSort) {
166
+ case 'paid':
167
+ return (itemEarningsMap[b.calcutta_auction_item_id] || 0) - (itemEarningsMap[a.calcutta_auction_item_id] || 0);
168
+ case 'bid':
169
+ return Number(b.winning_bid || 0) - Number(a.winning_bid || 0);
170
+ case 'name':
171
+ default:
172
+ return a.item_name.localeCompare(b.item_name);
124
173
  }
125
- return false;
126
174
  });
127
- }, [items, itemSearch, enrichedPlayers]);
175
+ }, [items, itemSearch, enrichedPlayers, itemSort, itemEarningsMap]);
176
+
177
+ const pagedItems = useMemo(() => {
178
+ const start = itemPage * ITEMS_PER_PAGE;
179
+ return filteredItems.slice(start, start + ITEMS_PER_PAGE);
180
+ }, [filteredItems, itemPage]);
181
+
182
+ const totalItemPages = Math.ceil(filteredItems.length / ITEMS_PER_PAGE);
128
183
 
129
184
  if (loading && !competition) {
130
185
  return (
@@ -213,42 +268,70 @@ export const CalcuttaResults: React.FC<CalcuttaResultsProps> = ({
213
268
  );
214
269
  };
215
270
 
216
- const renderPotKPIs = () => (
217
- <View variant="transparent" style={{ flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 8, gap: 10 }}>
218
- {!isSweepstakes && (
219
- <View variant="transparent" style={[styles.kpiCard, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle }]}>
220
- <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>Pot</Text>
221
- <Text variant="h3" bold style={{ color: '#F59E0B' }}>{formatCurrency(totalPot, marketType)}</Text>
222
- </View>
223
- )}
224
- {!isSweepstakes && myTotalSpent > 0 && (
225
- <View variant="transparent" style={[styles.kpiCard, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle }]}>
226
- <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>You Spent</Text>
227
- <Text variant="h3" bold>{formatCurrency(myTotalSpent, marketType)}</Text>
228
- </View>
229
- )}
230
- {myTotalEarned > 0 && (
231
- <View variant="transparent" style={[styles.kpiCard, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle }]}>
232
- <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>You Earned</Text>
233
- <Text variant="h3" bold style={{ color: '#10B981' }}>{formatCurrency(myTotalEarned, marketType)}</Text>
271
+ const renderPotKPIs = () => {
272
+ const kpiStyle = isDesktop
273
+ ? [styles.kpiCard, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle }]
274
+ : [styles.kpiCard, { flex: 0, backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle, minWidth: '31%' as any, flexGrow: 1 }];
275
+
276
+ return (
277
+ <View variant="transparent" style={{ flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 16, paddingVertical: 8, gap: 10 }}>
278
+ {!isSweepstakes && (
279
+ <View variant="transparent" style={kpiStyle}>
280
+ <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>Pot</Text>
281
+ <Text variant="h3" bold style={{ color: '#F59E0B' }}>{formatCurrency(totalPot, marketType)}</Text>
282
+ </View>
283
+ )}
284
+ {!isSweepstakes && unclaimedPot > 0 && (
285
+ <View variant="transparent" style={kpiStyle}>
286
+ <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>Unclaimed</Text>
287
+ <Text variant="h3" bold style={{ color: '#8B5CF6' }}>{formatCurrency(unclaimedPot, marketType)}</Text>
288
+ </View>
289
+ )}
290
+ {!isSweepstakes && myTotalSpent > 0 && (
291
+ <View variant="transparent" style={kpiStyle}>
292
+ <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>You Spent</Text>
293
+ <Text variant="h3" bold>{formatCurrency(myTotalSpent, marketType)}</Text>
294
+ </View>
295
+ )}
296
+ {myTotalEarned > 0 && (
297
+ <View variant="transparent" style={kpiStyle}>
298
+ <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>You Earned</Text>
299
+ <Text variant="h3" bold style={{ color: '#10B981' }}>{formatCurrency(myTotalEarned, marketType)}</Text>
300
+ </View>
301
+ )}
302
+ <View variant="transparent" style={kpiStyle}>
303
+ <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>Players</Text>
304
+ <Text variant="h3" bold>{participants.length}</Text>
234
305
  </View>
235
- )}
236
- <View variant="transparent" style={[styles.kpiCard, { backgroundColor: theme.colors.surface.elevated, borderColor: theme.colors.border.subtle }]}>
237
- <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>Players</Text>
238
- <Text variant="h3" bold>{participants.length}</Text>
239
306
  </View>
240
- </View>
241
- );
307
+ );
308
+ };
242
309
 
243
310
  const renderRounds = () => {
244
311
  if (roundSummaries.length === 0) return null;
245
312
  const sortedRounds = [...roundSummaries].sort((a, b) => a.round.round_number - b.round.round_number);
313
+
314
+ // Compute running unclaimed balance per round
315
+ // Each closed round: pool = (pct * pot) + rolledIn, paid out = total_payout, rolled out = pool - paid
316
+ const roundUnclaimed: { rolledIn: number; pool: number; rolledOut: number }[] = [];
317
+ let runningUnclaimed = 0;
318
+ for (const rs of sortedRounds) {
319
+ const basePool = rs.payout_rule ? (rs.payout_rule.payout_pct / 100) * totalPot : 0;
320
+ const pool = basePool + runningUnclaimed;
321
+ const isClosed = rs.round.status === 'closed';
322
+ const rolledOut = isClosed ? Math.max(pool - rs.total_payout, 0) : 0;
323
+ roundUnclaimed.push({ rolledIn: runningUnclaimed, pool, rolledOut });
324
+ if (isClosed) { runningUnclaimed = rolledOut; }
325
+ }
326
+
246
327
  return (
247
328
  <View variant="transparent" style={{ paddingHorizontal: 16 }}>
248
329
  <Text variant="caption" bold color="tertiary" style={styles.sectionLabel}>Rounds</Text>
249
- {sortedRounds.map(rs => {
330
+ {sortedRounds.map((rs, idx) => {
250
331
  const isExpanded = expandedRound === rs.round.round_number;
251
332
  const isClosed = rs.round.status === 'closed';
333
+ const isFinalRound = idx === sortedRounds.length - 1;
334
+ const { rolledIn, pool: roundPool, rolledOut } = roundUnclaimed[idx];
252
335
  return (
253
336
  <TouchableOpacity
254
337
  key={rs.round.calcutta_round_id}
@@ -262,9 +345,15 @@ export const CalcuttaResults: React.FC<CalcuttaResultsProps> = ({
262
345
  {rs.payout_rule && (
263
346
  <Text variant="caption" bold style={{ color: '#F59E0B' }}>{rs.payout_rule.payout_pct}%</Text>
264
347
  )}
265
- {rs.total_payout > 0 && (
348
+ {isClosed && rs.payout_rule && !isSweepstakes && rs.total_payout > 0 && (
266
349
  <Text variant="caption" bold style={{ color: '#10B981', marginLeft: 8 }}>{formatCurrency(rs.total_payout, marketType)}</Text>
267
350
  )}
351
+ {isClosed && rs.payout_rule && !isSweepstakes && rs.total_payout < 0.01 && !isFinalRound && (
352
+ <Text variant="caption" bold style={{ color: '#8B5CF6', marginLeft: 8 }}>Unclaimed</Text>
353
+ )}
354
+ {isClosed && isFinalRound && !isSweepstakes && runningUnclaimed > 0.01 && (
355
+ <Text variant="caption" bold style={{ color: '#10B981', marginLeft: 8 }}>Redistributed</Text>
356
+ )}
268
357
  <Ionicons name={isExpanded ? 'chevron-up' : 'chevron-down'} size={14} color={theme.colors.text.tertiary} style={{ marginLeft: 8 }} />
269
358
  </View>
270
359
  {!isExpanded && isClosed && (
@@ -272,18 +361,64 @@ export const CalcuttaResults: React.FC<CalcuttaResultsProps> = ({
272
361
  {rs.advanced_items.length} advanced · {rs.eliminated_items.length} eliminated
273
362
  </Text>
274
363
  )}
364
+ {!isExpanded && !isClosed && rs.payout_rule && !isSweepstakes && (
365
+ <Text variant="caption" color="tertiary" style={{ marginTop: 4, fontSize: 11, lineHeight: 15 }}>
366
+ {formatCurrency(roundPool, marketType)} payout pool{rolledIn > 0.01 ? ` (incl. ${formatCurrency(rolledIn, marketType)} rolled in)` : ''}
367
+ </Text>
368
+ )}
275
369
  {isExpanded && (
276
370
  <View variant="transparent" style={{ marginTop: 10 }}>
371
+ {!isSweepstakes && rs.payout_rule && (
372
+ <View variant="transparent" style={{ marginBottom: 10, paddingVertical: 6, paddingHorizontal: 8, backgroundColor: theme.colors.surface.base, borderRadius: 6, gap: 4 }}>
373
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center' }}>
374
+ <Ionicons name="cash-outline" size={14} color="#F59E0B" />
375
+ <Text variant="caption" color="secondary" style={{ marginLeft: 6, flex: 1 }}>
376
+ {rs.payout_rule.payout_pct}% of pot{rolledIn > 0.01 ? ` + ${formatCurrency(rolledIn, marketType)} rolled in` : ''}
377
+ </Text>
378
+ <Text variant="caption" bold style={{ color: '#F59E0B' }}>
379
+ {formatCurrency(roundPool, marketType)}
380
+ </Text>
381
+ </View>
382
+ {isClosed && (
383
+ <>
384
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingLeft: 20 }}>
385
+ <Ionicons name="checkmark-circle-outline" size={12} color="#10B981" />
386
+ <Text variant="caption" style={{ marginLeft: 6, flex: 1, color: theme.colors.text.tertiary }}>Paid to owners</Text>
387
+ <Text variant="caption" bold style={{ color: '#10B981' }}>{formatCurrency(rs.total_payout, marketType)}</Text>
388
+ </View>
389
+ {rolledOut > 0.01 && !isFinalRound && (
390
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingLeft: 20 }}>
391
+ <Ionicons name="arrow-forward-outline" size={12} color="#8B5CF6" />
392
+ <Text variant="caption" style={{ marginLeft: 6, flex: 1, color: theme.colors.text.tertiary }}>Rolled to unclaimed</Text>
393
+ <Text variant="caption" bold style={{ color: '#8B5CF6' }}>{formatCurrency(rolledOut, marketType)}</Text>
394
+ </View>
395
+ )}
396
+ {isFinalRound && runningUnclaimed > 0.01 && (
397
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingLeft: 20 }}>
398
+ <Ionicons name="people-outline" size={12} color="#10B981" />
399
+ <Text variant="caption" style={{ marginLeft: 6, flex: 1, color: theme.colors.text.tertiary }}>Redistributed to all players by spend</Text>
400
+ <Text variant="caption" bold style={{ color: '#10B981' }}>{formatCurrency(runningUnclaimed, marketType)}</Text>
401
+ </View>
402
+ )}
403
+ </>
404
+ )}
405
+ </View>
406
+ )}
277
407
  {rs.advanced_items.length > 0 && (
278
408
  <>
279
409
  <Text variant="caption" bold style={{ color: '#10B981', marginBottom: 4, fontSize: 10, lineHeight: 13 }}>ADVANCED ({rs.advanced_items.length})</Text>
280
410
  {rs.advanced_items.map(item => {
281
411
  const isMe = item.winning_player_id == player_id;
412
+ const result = item_results.find(r => r.calcutta_auction_item_id === item.calcutta_auction_item_id && r.round_number === rs.round.round_number);
413
+ const payout = Number(result?.payout_earned || 0);
414
+ const owner = item.winning_player_id ? enrichedPlayers[item.winning_player_id] : undefined;
415
+ const ownerName = isMe ? 'You' : owner?.username || owner?.show_name || '';
282
416
  return (
283
417
  <View key={item.calcutta_auction_item_id} variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 3 }}>
284
418
  <Ionicons name="checkmark-circle" size={12} color="#10B981" />
285
- <Text variant="caption" style={{ marginLeft: 6, flex: 1 }}>{item.item_name}</Text>
286
- {isMe && <Text variant="caption" bold style={{ color: '#10B981', fontSize: 10, lineHeight: 13 }}>YOU</Text>}
419
+ <Text variant="caption" style={{ marginLeft: 6, flex: 1 }} numberOfLines={1}>{item.item_name}</Text>
420
+ {ownerName ? <Text variant="caption" color="tertiary" style={{ marginLeft: 4, fontSize: 10, lineHeight: 13 }}>{ownerName}</Text> : null}
421
+ {payout > 0 && <Text variant="caption" bold style={{ color: '#10B981', marginLeft: 6, fontSize: 10, lineHeight: 13 }}>{formatCurrency(payout, marketType)}</Text>}
287
422
  </View>
288
423
  );
289
424
  })}
@@ -294,16 +429,70 @@ export const CalcuttaResults: React.FC<CalcuttaResultsProps> = ({
294
429
  <Text variant="caption" bold style={{ color: theme.colors.status.error, marginTop: 8, marginBottom: 4, fontSize: 10, lineHeight: 13 }}>ELIMINATED ({rs.eliminated_items.length})</Text>
295
430
  {rs.eliminated_items.map(item => {
296
431
  const isMe = item.winning_player_id == player_id;
432
+ const result = item_results.find(r => r.calcutta_auction_item_id === item.calcutta_auction_item_id && r.round_number === rs.round.round_number);
433
+ const payout = Number(result?.payout_earned || 0);
434
+ const owner = item.winning_player_id ? enrichedPlayers[item.winning_player_id] : undefined;
435
+ const ownerName = isMe ? 'You' : owner?.username || owner?.show_name || '';
297
436
  return (
298
437
  <View key={item.calcutta_auction_item_id} variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 3, opacity: 0.6 }}>
299
438
  <Ionicons name="close-circle" size={12} color={theme.colors.status.error} />
300
- <Text variant="caption" style={{ marginLeft: 6, flex: 1 }}>{item.item_name}</Text>
301
- {isMe && <Text variant="caption" bold style={{ color: theme.colors.status.error, fontSize: 10, lineHeight: 13 }}>YOU</Text>}
439
+ <Text variant="caption" style={{ marginLeft: 6, flex: 1 }} numberOfLines={1}>{item.item_name}</Text>
440
+ {ownerName ? <Text variant="caption" color="tertiary" style={{ marginLeft: 4, fontSize: 10, lineHeight: 13 }}>{ownerName}</Text> : null}
441
+ {payout > 0 && <Text variant="caption" bold style={{ color: '#10B981', marginLeft: 6, fontSize: 10, lineHeight: 13 }}>{formatCurrency(payout, marketType)}</Text>}
302
442
  </View>
303
443
  );
304
444
  })}
305
445
  </>
306
446
  )}
447
+ {isClosed && rs.advanced_items.length === 0 && rs.eliminated_items.length === 0 && (
448
+ <Text variant="caption" color="tertiary" style={{ fontSize: 11, lineHeight: 15, fontStyle: 'italic' }}>
449
+ No items remaining — round resolved with empty field
450
+ </Text>
451
+ )}
452
+ {isClosed && !isSweepstakes && rolledOut > 0.01 && !isFinalRound && (
453
+ <View variant="transparent" style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', backgroundColor: '#8B5CF610', paddingVertical: 6, paddingHorizontal: 8, borderRadius: 6 }}>
454
+ <Ionicons name="arrow-forward-outline" size={14} color="#8B5CF6" />
455
+ <Text variant="caption" style={{ marginLeft: 6, color: '#8B5CF6', flex: 1 }}>
456
+ {formatCurrency(rolledOut, marketType)} unclaimed — rolls forward to next round
457
+ </Text>
458
+ </View>
459
+ )}
460
+ {isClosed && !isSweepstakes && isFinalRound && runningUnclaimed > 0.01 && (() => {
461
+ const totalSpentAll = participants.reduce((s, p) => s + Number(p.total_spent), 0);
462
+ const playerShares = totalSpentAll > 0
463
+ ? participants
464
+ .filter(p => Number(p.total_spent) > 0)
465
+ .map(p => {
466
+ const pct = (Number(p.total_spent) / totalSpentAll) * 100;
467
+ const share = (pct / 100) * runningUnclaimed;
468
+ const profile = enrichedPlayers[p.player_id];
469
+ const isMe = p.player_id == player_id;
470
+ const name = isMe ? 'You' : profile?.username || profile?.show_name || 'Player';
471
+ return { name, share, pct, isMe };
472
+ })
473
+ .sort((a, b) => b.share - a.share)
474
+ : [];
475
+ return (
476
+ <View variant="transparent" style={{ marginTop: 8, backgroundColor: '#10B98110', paddingVertical: 8, paddingHorizontal: 10, borderRadius: 6 }}>
477
+ <View variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 6 }}>
478
+ <Ionicons name="people-outline" size={14} color="#10B981" />
479
+ <Text variant="caption" bold style={{ marginLeft: 6, color: '#10B981' }}>
480
+ {formatCurrency(runningUnclaimed, marketType)} redistributed by spend
481
+ </Text>
482
+ </View>
483
+ {playerShares.map((ps, i) => (
484
+ <View key={i} variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 2, paddingLeft: 20 }}>
485
+ <Text variant="caption" style={{ flex: 1, color: ps.isMe ? '#10B981' : theme.colors.text.secondary }} numberOfLines={1}>
486
+ {ps.name} <Text variant="caption" color="tertiary" style={{ fontSize: 10, lineHeight: 13 }}>({ps.pct.toFixed(1)}%)</Text>
487
+ </Text>
488
+ <Text variant="caption" bold style={{ color: '#10B981' }}>
489
+ {formatCurrency(ps.share, marketType)}
490
+ </Text>
491
+ </View>
492
+ ))}
493
+ </View>
494
+ );
495
+ })()}
307
496
  {rs.prize_description && (
308
497
  <View variant="transparent" style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center' }}>
309
498
  <Ionicons name="gift" size={14} color="#8B5CF6" />
@@ -319,71 +508,213 @@ export const CalcuttaResults: React.FC<CalcuttaResultsProps> = ({
319
508
  );
320
509
  };
321
510
 
322
- const renderLeaderboard = () => (
323
- <View variant="transparent" style={{ paddingHorizontal: 16 }}>
324
- <Text variant="caption" bold color="tertiary" style={styles.sectionLabel}>Leaderboard</Text>
325
- {leaderboard.map((p, i) => {
326
- const profile = enrichedPlayers[p.player_id];
327
- const username = profile?.username || profile?.show_name || `Player`;
328
- const profilePic = profile?.profile_pic;
329
- const isMe = p.player_id == player_id;
330
- const medals = ['🥇', '🥈', '🥉'];
331
- return (
332
- <View key={p.calcutta_participant_id} variant="transparent" style={[styles.leaderRow, { borderColor: theme.colors.border.subtle }]}>
333
- <Text style={{ width: 28, textAlign: 'center', fontSize: 14, lineHeight: 19 }}>{i < 3 ? medals[i] : `${i + 1}`}</Text>
334
- {profilePic ? (
335
- <Image source={{ uri: profilePic }} style={{ width: 28, height: 28, borderRadius: 14 }} />
336
- ) : (
337
- <View variant="transparent" style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: isMe ? '#10B98120' : theme.colors.primary.subtle, alignItems: 'center', justifyContent: 'center' }}>
338
- <Text variant="caption" bold style={{ fontSize: 12, lineHeight: 16, color: isMe ? '#10B981' : theme.colors.primary.default }}>{username.charAt(0).toUpperCase()}</Text>
511
+ const renderLeaderboard = () => {
512
+ const medals = ['🥇', '🥈', '🥉'];
513
+
514
+ // Map original rank from the full leaderboard
515
+ const rankMap = new Map<string, number>();
516
+ leaderboard.forEach((p, i) => rankMap.set(p.calcutta_participant_id, i));
517
+
518
+ return (
519
+ <View variant="transparent" style={{ paddingHorizontal: 16 }}>
520
+ <Text variant="caption" bold color="tertiary" style={styles.sectionLabel}>Leaderboard ({participants.length})</Text>
521
+
522
+ <TextInput
523
+ value={leaderSearch}
524
+ onChangeText={v => { setLeaderSearch(v); setLeaderPage(0); }}
525
+ placeholder="Search players..."
526
+ placeholderTextColor={theme.colors.text.tertiary}
527
+ style={{
528
+ color: theme.colors.text.primary,
529
+ backgroundColor: theme.colors.surface.elevated,
530
+ borderColor: theme.colors.border.subtle,
531
+ borderWidth: 1,
532
+ borderRadius: 8,
533
+ paddingHorizontal: 10,
534
+ paddingVertical: 8,
535
+ fontSize: 13,
536
+ marginBottom: 8,
537
+ }}
538
+ />
539
+
540
+ {pagedLeaderboard.map(p => {
541
+ const rank = rankMap.get(p.calcutta_participant_id) ?? 0;
542
+ const profile = enrichedPlayers[p.player_id];
543
+ const username = profile?.username || profile?.show_name || `Player`;
544
+ const profilePic = profile?.profile_pic;
545
+ const isMe = p.player_id == player_id;
546
+ return (
547
+ <View key={p.calcutta_participant_id} variant="transparent" style={[styles.leaderRow, { borderColor: theme.colors.border.subtle }]}>
548
+ <Text style={{ width: 28, textAlign: 'center', fontSize: 14, lineHeight: 19 }}>{rank < 3 ? medals[rank] : `${rank + 1}`}</Text>
549
+ {profilePic ? (
550
+ <Image source={{ uri: profilePic }} style={{ width: 28, height: 28, borderRadius: 14 }} />
551
+ ) : (
552
+ <View variant="transparent" style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: isMe ? '#10B98120' : theme.colors.primary.subtle, alignItems: 'center', justifyContent: 'center' }}>
553
+ <Text variant="caption" bold style={{ fontSize: 12, lineHeight: 16, color: isMe ? '#10B981' : theme.colors.primary.default }}>{username.charAt(0).toUpperCase()}</Text>
554
+ </View>
555
+ )}
556
+ <View variant="transparent" style={{ flex: 1, marginLeft: 8 }}>
557
+ <Text variant="body" bold={isMe} style={isMe ? { color: '#10B981' } : undefined}>{isMe ? 'You' : username}</Text>
558
+ <Text variant="caption" color="tertiary">{p.items_owned} item{p.items_owned !== 1 ? 's' : ''} · {formatCurrency(p.total_spent, marketType)} spent{totalPot > 0 ? ` · ${((Number(p.total_spent) / totalPot) * 100).toFixed(1)}%` : ''}</Text>
339
559
  </View>
340
- )}
341
- <View variant="transparent" style={{ flex: 1, marginLeft: 8 }}>
342
- <Text variant="body" bold={isMe} style={isMe ? { color: '#10B981' } : undefined}>{isMe ? 'You' : username}</Text>
343
- <Text variant="caption" color="tertiary">{p.items_owned} item{p.items_owned !== 1 ? 's' : ''}</Text>
560
+ {Number(p.total_spent) > 0 && (
561
+ <View variant="transparent" style={{ alignItems: 'flex-end' }}>
562
+ <Text variant="caption" bold style={{ color: '#10B981' }}>{formatCurrency(p.total_winnings, marketType)}</Text>
563
+ {(() => {
564
+ const roi = ((Number(p.total_winnings) - Number(p.total_spent)) / Number(p.total_spent)) * 100;
565
+ return (
566
+ <Text variant="caption" style={{ color: roi >= 0 ? '#10B981' : theme.colors.status.error, fontSize: 10, lineHeight: 13 }}>
567
+ {roi >= 0 ? '+' : ''}{roi.toFixed(1)}%
568
+ </Text>
569
+ );
570
+ })()}
571
+ </View>
572
+ )}
344
573
  </View>
345
- {Number(p.total_winnings) > 0 && (
346
- <Text variant="caption" bold style={{ color: '#10B981' }}>{formatCurrency(p.total_winnings, marketType)}</Text>
347
- )}
574
+ );
575
+ })}
576
+
577
+ {totalLeaderPages > 1 && (
578
+ <View variant="transparent" style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 12, gap: 12 }}>
579
+ <TouchableOpacity
580
+ onPress={() => setLeaderPage(p => Math.max(0, p - 1))}
581
+ disabled={leaderPage === 0}
582
+ style={{ opacity: leaderPage === 0 ? 0.3 : 1 }}
583
+ >
584
+ <Ionicons name="chevron-back" size={20} color={theme.colors.text.secondary} />
585
+ </TouchableOpacity>
586
+ <Text variant="caption" color="secondary">
587
+ {leaderPage + 1} / {totalLeaderPages}
588
+ </Text>
589
+ <TouchableOpacity
590
+ onPress={() => setLeaderPage(p => Math.min(totalLeaderPages - 1, p + 1))}
591
+ disabled={leaderPage >= totalLeaderPages - 1}
592
+ style={{ opacity: leaderPage >= totalLeaderPages - 1 ? 0.3 : 1 }}
593
+ >
594
+ <Ionicons name="chevron-forward" size={20} color={theme.colors.text.secondary} />
595
+ </TouchableOpacity>
348
596
  </View>
349
- );
350
- })}
351
- </View>
352
- );
597
+ )}
598
+ </View>
599
+ );
600
+ };
601
+
602
+ const renderAllItems = () => {
603
+ const sortOptions: { key: ItemSort; label: string }[] = [
604
+ { key: 'paid', label: 'Most Paid' },
605
+ { key: 'bid', label: 'Highest Bid' },
606
+ { key: 'name', label: 'Name' },
607
+ ];
608
+
609
+ return (
610
+ <View variant="transparent" style={{ paddingHorizontal: 16, paddingBottom: 40 }}>
611
+ <Text variant="caption" bold color="tertiary" style={styles.sectionLabel}>All Items ({items.length})</Text>
612
+
613
+ {/* Search */}
614
+ <TextInput
615
+ value={itemSearch}
616
+ onChangeText={v => { setItemSearch(v); setItemPage(0); }}
617
+ placeholder="Search items or owners..."
618
+ placeholderTextColor={theme.colors.text.tertiary}
619
+ style={{
620
+ color: theme.colors.text.primary,
621
+ backgroundColor: theme.colors.surface.elevated,
622
+ borderColor: theme.colors.border.subtle,
623
+ borderWidth: 1,
624
+ borderRadius: 8,
625
+ paddingHorizontal: 10,
626
+ paddingVertical: 8,
627
+ fontSize: 13,
628
+ marginBottom: 8,
629
+ }}
630
+ />
631
+
632
+ {/* Sort pills */}
633
+ <View variant="transparent" style={{ flexDirection: 'row', gap: 6, marginBottom: 10 }}>
634
+ {sortOptions.map(opt => {
635
+ const active = itemSort === opt.key;
636
+ return (
637
+ <TouchableOpacity
638
+ key={opt.key}
639
+ onPress={() => { setItemSort(opt.key); setItemPage(0); }}
640
+ style={{
641
+ paddingHorizontal: 10,
642
+ paddingVertical: 4,
643
+ borderRadius: 12,
644
+ backgroundColor: active ? theme.colors.primary.default : theme.colors.surface.elevated,
645
+ borderWidth: 1,
646
+ borderColor: active ? theme.colors.primary.default : theme.colors.border.subtle,
647
+ }}
648
+ >
649
+ <Text variant="caption" bold={active} style={{ color: active ? '#FFF' : theme.colors.text.secondary, fontSize: 11, lineHeight: 15 }}>
650
+ {opt.label}
651
+ </Text>
652
+ </TouchableOpacity>
653
+ );
654
+ })}
655
+ </View>
353
656
 
354
- const renderAllItems = () => (
355
- <View variant="transparent" style={{ paddingHorizontal: 16, paddingBottom: 40 }}>
356
- <Text variant="caption" bold color="tertiary" style={styles.sectionLabel}>All Items ({items.length})</Text>
357
- {filteredItems.map(item => {
358
- const imgUrl = resolveItemImageUrl(item.item_image) || itemImages[item.item_id]?.url;
359
- const owner = item.winning_player_id ? enrichedPlayers[item.winning_player_id] : undefined;
360
- const ownerName = owner?.username || owner?.show_name;
361
- const isMe = item.winning_player_id == player_id;
362
- return (
363
- <View key={item.calcutta_auction_item_id} variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderBottomWidth: 1, borderColor: theme.colors.border.subtle }}>
364
- {imgUrl ? (
365
- <Image source={{ uri: imgUrl }} style={{ width: 28, height: 28, borderRadius: 6 }} resizeMode="cover" />
366
- ) : (
367
- <View variant="transparent" style={{ width: 28, height: 28, borderRadius: 6, backgroundColor: theme.colors.surface.elevated, alignItems: 'center', justifyContent: 'center' }}>
368
- <Ionicons name="trophy-outline" size={14} color={theme.colors.text.tertiary} />
657
+ {/* Items */}
658
+ {pagedItems.map(item => {
659
+ const imgUrl = resolveItemImageUrl(item.item_image) || itemImages[item.item_id]?.url;
660
+ const owner = item.winning_player_id ? enrichedPlayers[item.winning_player_id] : undefined;
661
+ const ownerName = owner?.username || owner?.show_name;
662
+ const isMe = item.winning_player_id == player_id;
663
+ const earned = itemEarningsMap[item.calcutta_auction_item_id] || 0;
664
+ return (
665
+ <View key={item.calcutta_auction_item_id} variant="transparent" style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderBottomWidth: 1, borderColor: theme.colors.border.subtle }}>
666
+ {imgUrl ? (
667
+ <Image source={{ uri: imgUrl }} style={{ width: 28, height: 28, borderRadius: 6 }} resizeMode="cover" />
668
+ ) : (
669
+ <View variant="transparent" style={{ width: 28, height: 28, borderRadius: 6, backgroundColor: theme.colors.surface.elevated, alignItems: 'center', justifyContent: 'center' }}>
670
+ <Ionicons name="trophy-outline" size={14} color={theme.colors.text.tertiary} />
671
+ </View>
672
+ )}
673
+ <View variant="transparent" style={{ marginLeft: 8, flex: 1 }}>
674
+ <Text variant="body" numberOfLines={1}>{item.item_name}</Text>
675
+ <Text variant="caption" color="tertiary">
676
+ {ownerName ? (isMe ? 'You' : ownerName) : 'Unowned'}
677
+ {item.status === 'eliminated' ? ' · Eliminated' : item.status === 'sold' ? '' : ` · ${getStatusLabel(item.status)}`}
678
+ </Text>
679
+ </View>
680
+ <View variant="transparent" style={{ alignItems: 'flex-end' }}>
681
+ {Number(item.winning_bid) > 0 && !isSweepstakes && (
682
+ <Text variant="caption" bold>{formatCurrency(item.winning_bid, marketType)}</Text>
683
+ )}
684
+ {earned > 0 && (
685
+ <Text variant="caption" bold style={{ color: '#10B981', fontSize: 10, lineHeight: 13 }}>{formatCurrency(earned, marketType)} earned</Text>
686
+ )}
369
687
  </View>
370
- )}
371
- <View variant="transparent" style={{ marginLeft: 8, flex: 1 }}>
372
- <Text variant="body">{item.item_name}</Text>
373
- <Text variant="caption" color="tertiary">
374
- {ownerName ? (isMe ? 'You' : ownerName) : 'Unowned'}
375
- {item.status === 'eliminated' ? ' · Eliminated' : item.status === 'sold' ? '' : ` · ${getStatusLabel(item.status)}`}
376
- </Text>
688
+ {isMe && <Text variant="caption" bold style={{ color: '#10B981', marginLeft: 6, fontSize: 10, lineHeight: 13 }}>YOU</Text>}
377
689
  </View>
378
- {Number(item.winning_bid) > 0 && !isSweepstakes && (
379
- <Text variant="caption" bold>{formatCurrency(item.winning_bid, marketType)}</Text>
380
- )}
381
- {isMe && <Text variant="caption" bold style={{ color: '#10B981', marginLeft: 6, fontSize: 10, lineHeight: 13 }}>YOU</Text>}
690
+ );
691
+ })}
692
+
693
+ {/* Pagination */}
694
+ {totalItemPages > 1 && (
695
+ <View variant="transparent" style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 12, gap: 12 }}>
696
+ <TouchableOpacity
697
+ onPress={() => setItemPage(p => Math.max(0, p - 1))}
698
+ disabled={itemPage === 0}
699
+ style={{ opacity: itemPage === 0 ? 0.3 : 1 }}
700
+ >
701
+ <Ionicons name="chevron-back" size={20} color={theme.colors.text.secondary} />
702
+ </TouchableOpacity>
703
+ <Text variant="caption" color="secondary">
704
+ {itemPage + 1} / {totalItemPages}
705
+ </Text>
706
+ <TouchableOpacity
707
+ onPress={() => setItemPage(p => Math.min(totalItemPages - 1, p + 1))}
708
+ disabled={itemPage >= totalItemPages - 1}
709
+ style={{ opacity: itemPage >= totalItemPages - 1 ? 0.3 : 1 }}
710
+ >
711
+ <Ionicons name="chevron-forward" size={20} color={theme.colors.text.secondary} />
712
+ </TouchableOpacity>
382
713
  </View>
383
- );
384
- })}
385
- </View>
386
- );
714
+ )}
715
+ </View>
716
+ );
717
+ };
387
718
 
388
719
  // ═══ RENDER ═══
389
720
 
@@ -418,14 +749,38 @@ export const CalcuttaResults: React.FC<CalcuttaResultsProps> = ({
418
749
  </View>
419
750
  </ScrollView>
420
751
  ) : (
421
- <ScrollView style={{ flex: 1, backgroundColor: theme.colors.surface.base }} contentContainerStyle={{ paddingBottom: Platform.OS === 'web' ? 300 : 40, backgroundColor: theme.colors.surface.base }} keyboardShouldPersistTaps="handled" refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}>
422
- {renderHeader()}
423
- {renderMyItems()}
424
- {renderPotKPIs()}
425
- {renderRounds()}
426
- {renderLeaderboard()}
427
- {renderAllItems()}
428
- </ScrollView>
752
+ <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}>
753
+ <ScrollView style={{ flex: 1, backgroundColor: theme.colors.surface.base }} contentContainerStyle={{ paddingBottom: 40, backgroundColor: theme.colors.surface.base }} keyboardShouldPersistTaps="handled" refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}>
754
+ {renderHeader()}
755
+ {renderMyItems()}
756
+ {renderPotKPIs()}
757
+ <View variant="transparent" style={{ flexDirection: 'row', marginHorizontal: 16, marginTop: 8, marginBottom: 4, borderRadius: 10, backgroundColor: theme.colors.surface.elevated, borderWidth: 1, borderColor: theme.colors.border.subtle, overflow: 'hidden' }}>
758
+ {([
759
+ { key: 'rounds' as BottomTab, label: 'Rounds', icon: 'layers-outline' as const },
760
+ { key: 'leaderboard' as BottomTab, label: 'Leaderboard', icon: 'podium-outline' as const },
761
+ { key: 'items' as BottomTab, label: 'Items', icon: 'list-outline' as const },
762
+ ]).map(tab => {
763
+ const active = mobileTab === tab.key;
764
+ return (
765
+ <TouchableOpacity
766
+ key={tab.key}
767
+ onPress={() => setMobileTab(tab.key)}
768
+ style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, height: 44, backgroundColor: active ? theme.colors.primary.default : 'transparent' }}
769
+ activeOpacity={0.7}
770
+ >
771
+ <Ionicons name={tab.icon} size={14} color={active ? '#FFF' : theme.colors.text.tertiary} />
772
+ <Text variant="caption" bold={active} style={{ color: active ? '#FFF' : theme.colors.text.tertiary }}>
773
+ {tab.label}
774
+ </Text>
775
+ </TouchableOpacity>
776
+ );
777
+ })}
778
+ </View>
779
+ {mobileTab === 'rounds' && renderRounds()}
780
+ {mobileTab === 'leaderboard' && renderLeaderboard()}
781
+ {mobileTab === 'items' && renderAllItems()}
782
+ </ScrollView>
783
+ </KeyboardAvoidingView>
429
784
  )}
430
785
  </View>
431
786
  );