diff --git a/backend/src/analytics/analytics.service.spec.ts b/backend/src/analytics/analytics.service.spec.ts index 296a54f6..9f7f2974 100644 --- a/backend/src/analytics/analytics.service.spec.ts +++ b/backend/src/analytics/analytics.service.spec.ts @@ -46,7 +46,7 @@ describe('AnalyticsService', () => { let module: TestingModule; let usersRepository: jest.Mocked, 'findOne'>>; let predictionsRepository: jest.Mocked< - Pick, 'createQueryBuilder'> + Pick, 'createQueryBuilder' | 'find'> >; let leaderboardRepository: jest.Mocked< Pick, 'createQueryBuilder'> @@ -78,7 +78,7 @@ describe('AnalyticsService', () => { beforeEach(async () => { usersRepository = { findOne: jest.fn() }; leaderboardRepository = { createQueryBuilder: jest.fn() }; - predictionsRepository = { createQueryBuilder: jest.fn() }; + predictionsRepository = { createQueryBuilder: jest.fn(), find: jest.fn() }; marketHistoryRepository = { createQueryBuilder: jest.fn() }; module = await Test.createTestingModule({ @@ -302,5 +302,233 @@ describe('AnalyticsService', () => { 'Market "invalid" not found', ); }); + + it('should apply to/from date filters when provided', async () => { + const mockMarket = { id: 'market-1', title: 'Market 1' } as Market; + const marketsRepository = module.get(getRepositoryToken(Market)); + const marketHistoryRepository = module.get( + getRepositoryToken(MarketHistory), + ); + + jest.spyOn(marketsRepository, 'findOne').mockResolvedValue(mockMarket); + + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; + jest + .spyOn(marketHistoryRepository, 'createQueryBuilder') + .mockReturnValue(qb as any); + + await service.getMarketHistory( + 'market-1', + '2025-01-01', + '2025-12-31', + ); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'history.recorded_at >= :from', + expect.any(Object), + ); + expect(qb.andWhere).toHaveBeenCalledWith( + 'history.recorded_at <= :to', + expect.any(Object), + ); + }); + }); + + describe('logActivity', () => { + it('should create and save an activity log entry', async () => { + const activityLogsRepository = module.get( + getRepositoryToken(ActivityLog), + ); + const mockLog = { + userId: 'user-id-1', + actionType: 'prediction_submitted', + } as ActivityLog; + + jest.spyOn(activityLogsRepository, 'create').mockReturnValue(mockLog); + jest.spyOn(activityLogsRepository, 'save').mockResolvedValue(mockLog); + + const result = await service.logActivity( + 'user-id-1', + 'prediction_submitted', + { marketId: 'market-1' }, + '192.168.1.1', + ); + + expect(activityLogsRepository.create).toHaveBeenCalledWith({ + userId: 'user-id-1', + actionType: 'prediction_submitted', + actionDetails: { marketId: 'market-1' }, + ipAddress: '192.168.1.1', + }); + expect(result).toEqual(mockLog); + }); + }); + + describe('getMarketAnalytics', () => { + it('should return market analytics with outcome distribution', async () => { + const mockMarket = { + id: 'market-1', + title: 'Will ETH reach $5k?', + total_pool_stroops: '50000000', + participant_count: 3, + outcome_options: ['Yes', 'No'], + end_time: new Date(Date.now() + 3600 * 1000).toISOString(), + } as unknown as Market; + + const mockPredictions = [ + { chosen_outcome: 'Yes' }, + { chosen_outcome: 'Yes' }, + { chosen_outcome: 'No' }, + ] as any[]; + + const marketsRepository = module.get(getRepositoryToken(Market)); + const predictionsRepository = module.get(getRepositoryToken(Prediction)); + + jest.spyOn(marketsRepository, 'findOne').mockResolvedValue(mockMarket); + jest + .spyOn(predictionsRepository, 'find') + .mockResolvedValue(mockPredictions); + + const result = await service.getMarketAnalytics('market-1'); + + expect(result.market_id).toBe('market-1'); + expect(result.total_pool_stroops).toBe('50000000'); + expect(result.participant_count).toBe(3); + expect(result.outcome_distribution).toHaveLength(2); + + const yesEntry = result.outcome_distribution.find( + (o) => o.outcome === 'Yes', + ); + expect(yesEntry?.count).toBe(2); + expect(yesEntry?.percentage).toBeCloseTo(66.67, 1); + + const noEntry = result.outcome_distribution.find( + (o) => o.outcome === 'No', + ); + expect(noEntry?.count).toBe(1); + }); + + it('should throw NotFoundException for unknown market', async () => { + const marketsRepository = module.get(getRepositoryToken(Market)); + jest.spyOn(marketsRepository, 'findOne').mockResolvedValue(null); + + await expect(service.getMarketAnalytics('unknown')).rejects.toThrow( + 'Market "unknown" not found', + ); + }); + }); + + describe('getCategoryAnalytics', () => { + it('should aggregate markets by category', async () => { + const mockMarkets = [ + { + category: 'Crypto', + is_resolved: false, + is_cancelled: false, + total_pool_stroops: '10000000', + participant_count: 20, + }, + { + category: 'Crypto', + is_resolved: true, + is_cancelled: false, + total_pool_stroops: '5000000', + participant_count: 10, + }, + { + category: 'Sports', + is_resolved: false, + is_cancelled: false, + total_pool_stroops: '2000000', + participant_count: 5, + }, + ] as Market[]; + + const marketsRepository = module.get(getRepositoryToken(Market)); + jest.spyOn(marketsRepository, 'find').mockResolvedValue(mockMarkets); + + const result = await service.getCategoryAnalytics(); + + expect(result.categories).toHaveLength(2); + + const crypto = result.categories.find((c) => c.name === 'Crypto'); + expect(crypto?.total_markets).toBe(2); + expect(crypto?.active_markets).toBe(1); + expect(crypto?.total_volume_stroops).toBe('15000000'); + expect(crypto?.trending).toBe(false); // 1/2 = 50%, not > 50% + + const sports = result.categories.find((c) => c.name === 'Sports'); + expect(sports?.total_markets).toBe(1); + expect(sports?.active_markets).toBe(1); + expect(sports?.trending).toBe(true); // 1/1 = 100% > 50% + }); + + it('should sort categories by volume descending', async () => { + const mockMarkets = [ + { + category: 'Low', + is_resolved: false, + is_cancelled: false, + total_pool_stroops: '1000', + participant_count: 1, + }, + { + category: 'High', + is_resolved: false, + is_cancelled: false, + total_pool_stroops: '9000000', + participant_count: 50, + }, + ] as Market[]; + + const marketsRepository = module.get(getRepositoryToken(Market)); + jest.spyOn(marketsRepository, 'find').mockResolvedValue(mockMarkets); + + const result = await service.getCategoryAnalytics(); + + expect(result.categories[0].name).toBe('High'); + expect(result.categories[1].name).toBe('Low'); + }); + }); + + describe('getUserTrends', () => { + it('should throw NotFoundException for unknown user address', async () => { + usersRepository.findOne.mockResolvedValue(null); + + await expect( + service.getUserTrends('GUNKNOWN'), + ).rejects.toThrow('User with address GUNKNOWN not found'); + }); + + it('should return trend data for a known user', async () => { + usersRepository.findOne.mockResolvedValue(baseUser); + + const predictionsRepository = module.get(getRepositoryToken(Prediction)); + jest.spyOn(predictionsRepository, 'find').mockResolvedValue([]); + + const result = await service.getUserTrends('GADDR', 30); + + expect(result.address).toBe('GADDR'); + expect(Array.isArray(result.accuracy_trend)).toBe(true); + expect(Array.isArray(result.prediction_volume_trend)).toBe(true); + expect(Array.isArray(result.profit_loss_trend)).toBe(true); + expect(Array.isArray(result.category_performance)).toBe(true); + }); + + it('should clamp days to max 90', async () => { + usersRepository.findOne.mockResolvedValue(baseUser); + + const predictionsRepository = module.get(getRepositoryToken(Prediction)); + jest.spyOn(predictionsRepository, 'find').mockResolvedValue([]); + + const result = await service.getUserTrends('GADDR', 999); + + expect(result.address).toBe('GADDR'); + }); }); }); diff --git a/backend/src/contract/contract.service.spec.ts b/backend/src/contract/contract.service.spec.ts new file mode 100644 index 00000000..bce694ed --- /dev/null +++ b/backend/src/contract/contract.service.spec.ts @@ -0,0 +1,543 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { ContractService } from './contract.service'; + +jest.mock('@stellar/stellar-sdk', () => { + const rpcServerInstance = { + getAccount: jest.fn(), + simulateTransaction: jest.fn(), + }; + + return { + _rpcServerInstance: rpcServerInstance, + rpc: { + Server: jest.fn().mockReturnValue(rpcServerInstance), + Api: { + isSimulationError: jest.fn(), + }, + }, + Contract: jest.fn().mockReturnValue({ + call: jest.fn().mockReturnValue({ type: 'operation' }), + }), + Keypair: { + random: jest.fn().mockReturnValue({ + publicKey: jest.fn().mockReturnValue('GKEYTESTPUBLICKEY123456'), + }), + }, + TransactionBuilder: jest.fn().mockReturnValue({ + addOperation: jest.fn().mockReturnThis(), + setTimeout: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ type: 'transaction' }), + }), + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, + nativeToScVal: jest.fn().mockImplementation((val) => ({ val })), + scValToNative: jest.fn(), + Address: jest.fn().mockReturnValue({ + toScVal: jest.fn().mockReturnValue({ type: 'address-scval' }), + }), + xdr: {}, + }; +}); + +describe('ContractService', () => { + let service: ContractService; + let stellarMock: Record; + let rpcServerInstance: { + getAccount: jest.Mock; + simulateTransaction: jest.Mock; + }; + + const makeConfigService = (contractId: string) => ({ + get: jest.fn().mockImplementation((key: string) => { + const config: Record = { + SOROBAN_CONTRACT_ID: contractId, + STELLAR_NETWORK: 'testnet', + SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org', + }; + return config[key] ?? null; + }), + }); + + beforeEach(async () => { + stellarMock = jest.requireMock('@stellar/stellar-sdk'); + rpcServerInstance = stellarMock._rpcServerInstance as { + getAccount: jest.Mock; + simulateTransaction: jest.Mock; + }; + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ContractService, + { provide: ConfigService, useValue: makeConfigService('CTEST123456789') }, + ], + }).compile(); + + service = module.get(ContractService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('when contract ID is not configured', () => { + let unconfiguredService: ContractService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + ContractService, + { provide: ConfigService, useValue: makeConfigService('') }, + ], + }).compile(); + unconfiguredService = module.get(ContractService); + }); + + it('getEvent returns null', async () => { + expect(await unconfiguredService.getEvent('1')).toBeNull(); + }); + + it('getEventByCode returns null', async () => { + expect(await unconfiguredService.getEventByCode('CODE1')).toBeNull(); + }); + + it('getMatch returns null', async () => { + expect(await unconfiguredService.getMatch('1')).toBeNull(); + }); + + it('getPrediction returns null', async () => { + expect(await unconfiguredService.getPrediction('1')).toBeNull(); + }); + + it('getConfig returns null', async () => { + expect(await unconfiguredService.getConfig()).toBeNull(); + }); + + it('getEventMatches returns empty array', async () => { + expect(await unconfiguredService.getEventMatches('1')).toEqual([]); + }); + + it('getUserPredictions returns empty array', async () => { + expect( + await unconfiguredService.getUserPredictions('GADDR', '1'), + ).toEqual([]); + }); + + it('getEventParticipants returns empty array', async () => { + expect(await unconfiguredService.getEventParticipants('1')).toEqual([]); + }); + + it('getEventWinners returns empty array', async () => { + expect(await unconfiguredService.getEventWinners('1')).toEqual([]); + }); + + it('getCreationFee returns "0"', async () => { + expect(await unconfiguredService.getCreationFee()).toBe('0'); + }); + + it('isVerified returns false', async () => { + expect(await unconfiguredService.isVerified('GADDR')).toBe(false); + }); + + it('getEventStatistics returns null', async () => { + expect(await unconfiguredService.getEventStatistics('1')).toBeNull(); + }); + + it('getPredictionDistribution returns zeros', async () => { + expect(await unconfiguredService.getPredictionDistribution('1')).toEqual({ + teamA: 0, + teamB: 0, + draw: 0, + }); + }); + }); + + describe('getEvent', () => { + it('calls viewCall with get_event and returns event', async () => { + const mockEvent = { + eventId: '1', + inviteCode: 'ABCDEFGH', + creator: 'GCREATOR', + title: 'Test Event', + description: 'Description', + startTime: 1000000, + endTime: 2000000, + maxParticipants: 100, + participantCount: 10, + isActive: true, + }; + jest.spyOn(service as any, 'viewCall').mockResolvedValue(mockEvent); + + const result = await service.getEvent('1'); + + expect(result).toEqual(mockEvent); + expect((service as any).viewCall).toHaveBeenCalledWith( + 'get_event', + expect.any(Array), + ); + }); + + it('returns null when viewCall returns null', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(null); + expect(await service.getEvent('1')).toBeNull(); + }); + }); + + describe('getEventByCode', () => { + it('calls viewCall with get_event_by_code', async () => { + const mockEvent = { eventId: '2', inviteCode: 'CODE1234' }; + jest.spyOn(service as any, 'viewCall').mockResolvedValue(mockEvent); + + const result = await service.getEventByCode('CODE1234'); + + expect(result).toEqual(mockEvent); + expect((service as any).viewCall).toHaveBeenCalledWith( + 'get_event_by_code', + expect.any(Array), + ); + }); + }); + + describe('getMatch', () => { + it('calls viewCall with get_match', async () => { + const mockMatch = { + matchId: '1', + eventId: '1', + homeTeam: 'Team A', + awayTeam: 'Team B', + startTime: 1000000, + resolved: false, + outcome: null, + }; + jest.spyOn(service as any, 'viewCall').mockResolvedValue(mockMatch); + + const result = await service.getMatch('1'); + + expect(result).toEqual(mockMatch); + expect((service as any).viewCall).toHaveBeenCalledWith( + 'get_match', + expect.any(Array), + ); + }); + }); + + describe('getEventMatches', () => { + it('returns matches array from viewCall', async () => { + const matches = [ + { matchId: '1', homeTeam: 'A', awayTeam: 'B', resolved: false }, + ]; + jest.spyOn(service as any, 'viewCall').mockResolvedValue(matches); + + expect(await service.getEventMatches('1')).toEqual(matches); + expect((service as any).viewCall).toHaveBeenCalledWith( + 'get_event_matches', + expect.any(Array), + ); + }); + + it('returns empty array when viewCall returns null', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(null); + expect(await service.getEventMatches('1')).toEqual([]); + }); + }); + + describe('getPrediction', () => { + it('calls viewCall with get_prediction', async () => { + const mockPred = { predictionId: '1', matchId: '1' }; + jest.spyOn(service as any, 'viewCall').mockResolvedValue(mockPred); + + const result = await service.getPrediction('1'); + + expect(result).toEqual(mockPred); + }); + }); + + describe('getUserPredictions', () => { + it('returns predictions from viewCall', async () => { + const preds = [{ predictionId: '1' }, { predictionId: '2' }]; + jest.spyOn(service as any, 'viewCall').mockResolvedValue(preds); + + const result = await service.getUserPredictions('GADDR', '1'); + + expect(result).toEqual(preds); + expect((service as any).viewCall).toHaveBeenCalledWith( + 'get_user_predictions', + expect.any(Array), + ); + }); + + it('returns empty array when viewCall returns null', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(null); + expect(await service.getUserPredictions('GADDR', '1')).toEqual([]); + }); + }); + + describe('getEventParticipants', () => { + it('returns participants array', async () => { + const participants = [ + { address: 'GADDR', joinedAt: 1000, predictionCount: 5 }, + ]; + jest.spyOn(service as any, 'viewCall').mockResolvedValue(participants); + + expect(await service.getEventParticipants('1')).toEqual(participants); + }); + + it('returns empty array on null', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(null); + expect(await service.getEventParticipants('1')).toEqual([]); + }); + }); + + describe('getEventWinners', () => { + it('returns winners from viewCall', async () => { + const winners = [ + { address: 'GADDR', totalStake: '1000', payout: '2000' }, + ]; + jest.spyOn(service as any, 'viewCall').mockResolvedValue(winners); + + expect(await service.getEventWinners('1')).toEqual(winners); + }); + + it('returns empty array on null', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(null); + expect(await service.getEventWinners('1')).toEqual([]); + }); + }); + + describe('getConfig', () => { + it('returns contract config', async () => { + const mockConfig = { + admin: 'GADMIN', + aiAgent: 'GAIAGENT', + treasury: 'GTREASURY', + celoToken: 'GCELO', + creationFee: '5000000', + paused: false, + }; + jest.spyOn(service as any, 'viewCall').mockResolvedValue(mockConfig); + + const result = await service.getConfig(); + expect(result).toEqual(mockConfig); + }); + }); + + describe('getCreationFee', () => { + it('returns fee string from viewCall', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue('10000000'); + expect(await service.getCreationFee()).toBe('10000000'); + }); + + it('returns "0" when viewCall returns null', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(null); + expect(await service.getCreationFee()).toBe('0'); + }); + }); + + describe('isVerified', () => { + it('returns true for verified address', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(true); + expect(await service.isVerified('GADDR')).toBe(true); + }); + + it('returns false for unverified address', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(false); + expect(await service.isVerified('GADDR')).toBe(false); + }); + + it('returns false when viewCall returns null', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(null); + expect(await service.isVerified('GADDR')).toBe(false); + }); + }); + + describe('getEventStatistics', () => { + it('returns null for non-numeric eventId', async () => { + expect(await service.getEventStatistics('not-a-number')).toBeNull(); + }); + + it('returns null when viewCall returns null', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(null); + expect(await service.getEventStatistics('1')).toBeNull(); + }); + + it('maps snake_case contract fields to typed statistics', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue({ + event_id: 1, + participant_count: 50, + match_count: 5, + total_predictions: 200, + all_matches_resolved: true, + winners_verified: false, + winner_count: 3, + }); + + const result = await service.getEventStatistics('1'); + + expect(result).toEqual({ + eventId: '1', + participantCount: 50, + matchCount: 5, + totalPredictions: 200, + allMatchesResolved: true, + winnersVerified: false, + winnerCount: 3, + }); + }); + + it('maps camelCase fields from contract', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue({ + eventId: '42', + participantCount: 10, + matchCount: 2, + totalPredictions: 50, + allMatchesResolved: false, + winnersVerified: false, + winnerCount: 0, + }); + + const result = await service.getEventStatistics('42'); + expect(result?.participantCount).toBe(10); + expect(result?.matchCount).toBe(2); + expect(result?.eventId).toBe('42'); + }); + + it('defaults missing fields to 0/false', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue({}); + + const result = await service.getEventStatistics('1'); + expect(result?.participantCount).toBe(0); + expect(result?.allMatchesResolved).toBe(false); + }); + }); + + describe('getPredictionDistribution', () => { + it('returns zeros for non-numeric matchId', async () => { + expect(await service.getPredictionDistribution('abc')).toEqual({ + teamA: 0, + teamB: 0, + draw: 0, + }); + }); + + it('parses distribution tuple from viewCall', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue([60, 30, 10]); + + expect(await service.getPredictionDistribution('5')).toEqual({ + teamA: 60, + teamB: 30, + draw: 10, + }); + }); + + it('returns zeros when viewCall returns null', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue(null); + expect(await service.getPredictionDistribution('5')).toEqual({ + teamA: 0, + teamB: 0, + draw: 0, + }); + }); + + it('returns zeros when result is not an array', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue({ + invalid: true, + }); + expect(await service.getPredictionDistribution('5')).toEqual({ + teamA: 0, + teamB: 0, + draw: 0, + }); + }); + + it('returns zeros when array has fewer than 3 elements', async () => { + jest.spyOn(service as any, 'viewCall').mockResolvedValue([60]); + expect(await service.getPredictionDistribution('5')).toEqual({ + teamA: 0, + teamB: 0, + draw: 0, + }); + }); + }); + + describe('viewCall - RPC simulation', () => { + const mockAccount = { + accountId: () => 'GKEYTESTPUBLICKEY123456', + sequenceNumber: () => '0', + incrementSequenceNumber: jest.fn(), + }; + + it('returns null when simulation has no result retval', async () => { + rpcServerInstance.getAccount.mockResolvedValue(mockAccount); + rpcServerInstance.simulateTransaction.mockResolvedValue({ + result: null, + }); + stellarMock.rpc.Api.isSimulationError.mockReturnValue(false); + + expect(await service.getEvent('event-1')).toBeNull(); + }); + + it('returns null on simulation error', async () => { + rpcServerInstance.getAccount.mockResolvedValue(mockAccount); + rpcServerInstance.simulateTransaction.mockResolvedValue({ + error: 'Contract execution reverted', + }); + stellarMock.rpc.Api.isSimulationError.mockReturnValue(true); + + expect(await service.getEvent('event-1')).toBeNull(); + }); + + it('returns null when getAccount fails on all 3 attempts', async () => { + rpcServerInstance.getAccount + .mockRejectedValueOnce(new Error('Network timeout')) + .mockRejectedValueOnce(new Error('Network timeout')) + .mockRejectedValueOnce(new Error('Network timeout')); + + expect(await service.getEvent('event-1')).toBeNull(); + expect(rpcServerInstance.getAccount).toHaveBeenCalledTimes(3); + }); + + it('retries on transient failure and succeeds on second attempt', async () => { + const retval = { type: 'scval' }; + const mockData = { eventId: '1', title: 'Test' }; + + rpcServerInstance.getAccount + .mockRejectedValueOnce(new Error('Transient error')) + .mockResolvedValueOnce(mockAccount); + + rpcServerInstance.simulateTransaction.mockResolvedValue({ + result: { retval }, + }); + stellarMock.rpc.Api.isSimulationError.mockReturnValue(false); + stellarMock.scValToNative.mockReturnValue(mockData); + + const result = await service.getEvent('event-1'); + + expect(result).toEqual(mockData); + expect(rpcServerInstance.getAccount).toHaveBeenCalledTimes(2); + }); + + it('returns scValToNative result on successful simulation', async () => { + const retval = { type: 'contract-scval' }; + const nativeValue = { + eventId: '10', + title: 'My Event', + isActive: true, + }; + + rpcServerInstance.getAccount.mockResolvedValue(mockAccount); + rpcServerInstance.simulateTransaction.mockResolvedValue({ + result: { retval }, + }); + stellarMock.rpc.Api.isSimulationError.mockReturnValue(false); + stellarMock.scValToNative.mockReturnValue(nativeValue); + + const result = await service.getEvent('10'); + + expect(result).toEqual(nativeValue); + expect(stellarMock.scValToNative).toHaveBeenCalledWith(retval); + }); + }); +}); diff --git a/backend/src/notifications/notifications.service.spec.ts b/backend/src/notifications/notifications.service.spec.ts index 7c7e0ac4..52486728 100644 --- a/backend/src/notifications/notifications.service.spec.ts +++ b/backend/src/notifications/notifications.service.spec.ts @@ -201,4 +201,100 @@ describe('NotificationsService', () => { expect(mockRepository.softDelete).not.toHaveBeenCalled(); }); }); + + describe('markMultipleAsRead', () => { + it('should update multiple notifications as read', async () => { + mockRepository.update.mockResolvedValue({ affected: 2 }); + + const result = await service.markMultipleAsRead( + 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', + [1, 2], + ); + + expect(mockRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + user_address: 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', + }), + { read: true }, + ); + expect(result).toEqual({ updated: 2 }); + }); + + it('should return 0 when no notifications affected', async () => { + mockRepository.update.mockResolvedValue({ affected: undefined }); + + const result = await service.markMultipleAsRead( + 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', + [99], + ); + + expect(result).toEqual({ updated: 0 }); + }); + }); + + describe('getUnreadCount', () => { + it('should return unread count for a user', async () => { + mockRepository.count.mockResolvedValue(5); + + const count = await service.getUnreadCount( + 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', + ); + + expect(count).toBe(5); + expect(mockRepository.count).toHaveBeenCalledWith({ + where: { + user_address: 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', + read: false, + }, + }); + }); + + it('should return 0 when user has no unread notifications', async () => { + mockRepository.count.mockResolvedValue(0); + + const count = await service.getUnreadCount( + 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', + ); + + expect(count).toBe(0); + }); + }); + + describe('findAllForUser - type filter', () => { + it('should filter by notification type when provided', async () => { + mockRepository.findAndCount.mockResolvedValue([[], 0]); + mockRepository.count.mockResolvedValue(0); + + await service.findAllForUser( + 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', + 1, + 20, + undefined, + NotificationType.MatchAdded, + ); + + expect(mockRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + type: NotificationType.MatchAdded, + }), + }), + ); + }); + + it('should calculate correct skip offset for page 3', async () => { + mockRepository.findAndCount.mockResolvedValue([[], 0]); + mockRepository.count.mockResolvedValue(0); + + await service.findAllForUser( + 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XN', + 3, + 10, + ); + + expect(mockRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ skip: 20, take: 10 }), + ); + }); + }); }); diff --git a/frontend/src/app/(authenticated)/creator-events/[id]/matches/page.tsx b/frontend/src/app/(authenticated)/creator-events/[id]/matches/page.tsx new file mode 100644 index 00000000..21711d57 --- /dev/null +++ b/frontend/src/app/(authenticated)/creator-events/[id]/matches/page.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { + ChevronRight, + Clock, + Edit2, + Trash2, + ShieldCheck, + XCircle, + CheckCircle, +} from "lucide-react"; +import { Button } from "@/component/ui/button"; +import { useWallet } from "@/context/WalletContext"; +import AddMatchForm, { type MatchFormData } from "@/component/creator-events/AddMatchForm"; +import BulkMatchUpload from "@/component/creator-events/BulkMatchUpload"; + +type MatchStatus = "upcoming" | "started" | "resolved"; + +interface Match { + id: string; + teamA: string; + teamB: string; + matchTime: string; + status: MatchStatus; + hasPredictions: boolean; + result?: string; +} + +interface EventMeta { + id: string; + title: string; + creatorAddress: string; +} + +const MAX_MATCHES = 100; + +const MOCK_EVENT: EventMeta = { + id: "event-001", + title: "Apollo Tournament", + creatorAddress: "GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37", +}; + +const MOCK_MATCHES: Match[] = [ + { + id: "match-001", + teamA: "Team Alpha", + teamB: "Team Beta", + matchTime: new Date(Date.now() + 86400 * 1000).toISOString(), + status: "upcoming", + hasPredictions: false, + }, + { + id: "match-002", + teamA: "Team Gamma", + teamB: "Team Delta", + matchTime: new Date(Date.now() - 3600 * 1000).toISOString(), + status: "started", + hasPredictions: true, + }, + { + id: "match-003", + teamA: "Team Sigma", + teamB: "Team Omega", + matchTime: new Date(Date.now() - 86400 * 1000).toISOString(), + status: "resolved", + hasPredictions: true, + result: "Team Sigma", + }, +]; + +function statusBadge(status: MatchStatus) { + if (status === "upcoming") + return ( + + + Upcoming + + ); + if (status === "started") + return ( + + + Started + + ); + return ( + + + Resolved + + ); +} + +export default function MatchManagementPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + const { address } = useWallet(); + + const [eventMeta] = useState(MOCK_EVENT); + const [matches, setMatches] = useState(MOCK_MATCHES); + const [isCreator, setIsCreator] = useState(false); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setHydrated(true); + }, []); + + useEffect(() => { + if (!hydrated) return; + const normalized = address?.toUpperCase() ?? ""; + const creatorNorm = eventMeta.creatorAddress.toUpperCase(); + if (normalized !== creatorNorm) { + router.replace(`/creator-events/${params.id}`); + } else { + setIsCreator(true); + } + }, [hydrated, address, eventMeta.creatorAddress, params.id, router]); + + async function handleAddMatch(data: MatchFormData) { + const isDuplicate = matches.some( + (m) => + m.teamA.toLowerCase() === data.teamA.toLowerCase() && + m.teamB.toLowerCase() === data.teamB.toLowerCase(), + ); + if (isDuplicate) throw new Error("Duplicate match"); + + if (matches.length >= MAX_MATCHES) + throw new Error(`Maximum ${MAX_MATCHES} matches reached`); + + const newMatch: Match = { + id: `match-${Date.now()}`, + teamA: data.teamA, + teamB: data.teamB, + matchTime: data.matchTime, + status: "upcoming", + hasPredictions: false, + }; + setMatches((prev) => [...prev, newMatch]); + } + + async function handleBulkImport(bulk: MatchFormData[]) { + const newMatches: Match[] = bulk.map((m) => ({ + id: `match-${Date.now()}-${Math.random()}`, + teamA: m.teamA, + teamB: m.teamB, + matchTime: m.matchTime, + status: "upcoming", + hasPredictions: false, + })); + setMatches((prev) => [...prev, ...newMatches]); + } + + function handleDeleteMatch(id: string) { + setMatches((prev) => prev.filter((m) => m.id !== id)); + } + + if (!hydrated || !isCreator) { + return ( +
+
+
+

Verifying creator access…

+
+
+ ); + } + + return ( +
+
+ {/* Breadcrumb */} + + + {/* Header */} +
+

+ Match Management +

+

{eventMeta.title}

+

+ {matches.length}/{MAX_MATCHES} matches added +

+
+ + {/* Existing matches */} +
+

Existing Matches

+ + {matches.length === 0 ? ( +
+

No matches added yet.

+
+ ) : ( +
+ {matches.map((match) => ( +
+
+
+ {statusBadge(match.status)} + {match.result && ( + + Winner: {match.result} + + )} +
+

+ {match.teamA}{" "} + vs {match.teamB} +

+

+ + {new Date(match.matchTime).toLocaleString()} +

+
+ +
+ {match.status === "upcoming" && ( + + )} + {!match.hasPredictions && ( + + )} +
+
+ ))} +
+ )} +
+ + {/* Add match form */} + {matches.length < MAX_MATCHES && ( +
+

+ Add a Match +

+ +
+ )} + + {/* Bulk upload */} + {matches.length < MAX_MATCHES && ( +
+

+ Bulk Add via CSV +

+ +
+ )} + + {matches.length >= MAX_MATCHES && ( +
+ + Maximum of {MAX_MATCHES} matches reached. +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(authenticated)/creator-events/create/page.tsx b/frontend/src/app/(authenticated)/creator-events/create/page.tsx new file mode 100644 index 00000000..59d92fac --- /dev/null +++ b/frontend/src/app/(authenticated)/creator-events/create/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; +import CreateEventForm from "@/component/creator-events/CreateEventForm"; + +export default function CreateCreatorEventPage() { + return ( +
+
+ {/* Breadcrumb */} + + + {/* Header */} +
+

+ Creator Dashboard +

+

Create a New Event

+

+ Set up an invite-only prediction event. A one-time XLM creation fee is charged when + the event is published on-chain. +

+
+ + +
+
+ ); +} diff --git a/frontend/src/app/(oracle)/creator-events/page.tsx b/frontend/src/app/(oracle)/creator-events/page.tsx new file mode 100644 index 00000000..9c811b15 --- /dev/null +++ b/frontend/src/app/(oracle)/creator-events/page.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { useState } from "react"; +import { Clock, CheckCircle2, BarChart2, Timer } from "lucide-react"; +import PendingMatchesList, { + type PendingMatch, +} from "@/component/oracle/PendingMatchesList"; +import SubmitResultForm from "@/component/oracle/SubmitResultForm"; +import BatchResultSubmission from "@/component/oracle/BatchResultSubmission"; +import { Button } from "@/component/ui/button"; + +type Outcome = "TEAM_A" | "TEAM_B" | "DRAW"; + +interface SubmittedResult { + matchId: string; + teamA: string; + teamB: string; + outcome: Outcome; + submittedAt: string; + txHash: string; +} + +const MOCK_PENDING: PendingMatch[] = [ + { + matchId: "match-001", + onChainMatchId: "1", + teamA: "Team Alpha", + teamB: "Team Beta", + matchTime: new Date(Date.now() - 7200 * 1000).toISOString(), + predictionCount: 42, + eventId: "event-001", + eventTitle: "Apollo Tournament", + timeSinceStartedSeconds: 7200, + }, + { + matchId: "match-002", + onChainMatchId: "2", + teamA: "Team Gamma", + teamB: "Team Delta", + matchTime: new Date(Date.now() - 3600 * 1000).toISOString(), + predictionCount: 18, + eventId: "event-001", + eventTitle: "Apollo Tournament", + timeSinceStartedSeconds: 3600, + }, + { + matchId: "match-003", + onChainMatchId: "3", + teamA: "FC Barcelona", + teamB: "Real Madrid", + matchTime: new Date(Date.now() - 10800 * 1000).toISOString(), + predictionCount: 127, + eventId: "event-002", + eventTitle: "Rising Stars Invite", + timeSinceStartedSeconds: 10800, + }, +]; + +const MOCK_RECENT: SubmittedResult[] = [ + { + matchId: "match-000", + teamA: "Team X", + teamB: "Team Y", + outcome: "TEAM_A", + submittedAt: new Date(Date.now() - 86400 * 1000).toISOString(), + txHash: "abc123def456", + }, +]; + +const OUTCOME_LABELS: Record = { + TEAM_A: "Team A Won", + TEAM_B: "Team B Won", + DRAW: "Draw", +}; + +export default function OracleCreatorEventsPage() { + const [pending, setPending] = useState(MOCK_PENDING); + const [recentResults, setRecentResults] = + useState(MOCK_RECENT); + const [activeMatch, setActiveMatch] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [batchMode, setBatchMode] = useState(false); + const [activeTab, setActiveTab] = useState<"pending" | "batch" | "recent">( + "pending", + ); + + const todayCount = recentResults.filter( + (r) => + new Date(r.submittedAt).toDateString() === new Date().toDateString(), + ).length; + + const avgSubmissionTime = + pending.length > 0 + ? Math.round( + pending.reduce((sum, m) => sum + m.timeSinceStartedSeconds, 0) / + pending.length / + 60, + ) + : 0; + + async function handleSubmitResult( + matchId: string, + outcome: Outcome, + _confidence?: number, + _dataSource?: string, + ): Promise<{ txHash: string }> { + await new Promise((r) => setTimeout(r, 1500)); + const txHash = `tx${Date.now().toString(16)}`; + const match = pending.find((m) => m.matchId === matchId); + + if (match) { + setRecentResults((prev) => [ + { + matchId, + teamA: match.teamA, + teamB: match.teamB, + outcome, + submittedAt: new Date().toISOString(), + txHash, + }, + ...prev, + ]); + setPending((prev) => prev.filter((m) => m.matchId !== matchId)); + } + + return { txHash }; + } + + async function handleBatchSubmit( + results: Array<{ matchId: string; outcome: Outcome }>, + ) { + await new Promise((r) => setTimeout(r, 2000)); + const newResults: SubmittedResult[] = results.map((r) => { + const match = pending.find((m) => m.matchId === r.matchId); + return { + matchId: r.matchId, + teamA: match?.teamA ?? "Unknown", + teamB: match?.teamB ?? "Unknown", + outcome: r.outcome, + submittedAt: new Date().toISOString(), + txHash: `tx${Date.now().toString(16)}-${r.matchId}`, + }; + }); + setRecentResults((prev) => [...newResults, ...prev]); + setPending((prev) => + prev.filter((m) => !results.some((r) => r.matchId === m.matchId)), + ); + setSelectedIds(new Set()); + } + + function toggleSelect(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+ {/* Header */} +
+

+ AI Oracle +

+

Creator Event Results

+

+ Submit on-chain match results for creator events. All submissions are + signed by the AI Oracle wallet. +

+
+ + {/* Stats */} +
+ {[ + { + icon: Clock, + label: "Pending Matches", + value: pending.length, + color: "text-amber-300", + }, + { + icon: CheckCircle2, + label: "Total Submitted", + value: recentResults.length, + color: "text-emerald-300", + }, + { + icon: BarChart2, + label: "Submitted Today", + value: todayCount, + color: "text-sky-300", + }, + { + icon: Timer, + label: "Avg. Delay (min)", + value: avgSubmissionTime, + color: "text-violet-300", + }, + ].map(({ icon: Icon, label, value, color }) => ( +
+ +

+ {label} +

+

{value}

+
+ ))} +
+ + {/* Tabs */} +
+ {( + [ + { key: "pending", label: `Pending (${pending.length})` }, + { key: "batch", label: "Batch Submit" }, + { key: "recent", label: `Recent (${recentResults.length})` }, + ] as const + ).map(({ key, label }) => ( + + ))} +
+ + {/* Pending tab */} + {activeTab === "pending" && ( +
+
+

+ Matches Awaiting Results +

+ +
+ + + + {batchMode && selectedIds.size > 0 && ( +
+

+ {selectedIds.size} match{selectedIds.size !== 1 ? "es" : ""}{" "} + selected +

+ +
+ )} +
+ )} + + {/* Batch tab */} + {activeTab === "batch" && ( +
+

+ Batch Result Submission +

+ +
+ )} + + {/* Recent tab */} + {activeTab === "recent" && ( +
+

+ Recently Submitted Results +

+ {recentResults.length === 0 ? ( +
+ No results submitted yet. +
+ ) : ( +
+ {recentResults.map((r) => ( +
+
+

+ {r.teamA}{" "} + vs {r.teamB} +

+

+ {OUTCOME_LABELS[r.outcome]} +

+

+ {new Date(r.submittedAt).toLocaleString()} +

+
+ + {r.txHash.slice(0, 16)}… + +
+ ))} +
+ )} +
+ )} + + {/* Submit result modal */} + {activeMatch && ( + setActiveMatch(null)} + /> + )} +
+ ); +} diff --git a/frontend/src/app/(oracle)/layout.tsx b/frontend/src/app/(oracle)/layout.tsx new file mode 100644 index 00000000..75166cf2 --- /dev/null +++ b/frontend/src/app/(oracle)/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; +import OracleGuard from "@/component/oracle/OracleGuard"; +import OracleShell from "@/component/oracle/OracleShell"; + +export default function OracleLayout({ + children, +}: Readonly<{ children: ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/frontend/src/component/creator-events/AddMatchForm.tsx b/frontend/src/component/creator-events/AddMatchForm.tsx new file mode 100644 index 00000000..be5f2d55 --- /dev/null +++ b/frontend/src/component/creator-events/AddMatchForm.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Loader2 } from "lucide-react"; +import { Button } from "@/component/ui/button"; + +export interface MatchFormData { + teamA: string; + teamB: string; + matchTime: string; +} + +interface AddMatchFormProps { + onAddMatch: (data: MatchFormData) => Promise; +} + +const MAX_TEAM_NAME = 100; + +function nowPlusOneHour(): string { + const d = new Date(Date.now() + 3600 * 1000); + return d.toISOString().slice(0, 16); +} + +export default function AddMatchForm({ onAddMatch }: AddMatchFormProps) { + const [teamA, setTeamA] = useState(""); + const [teamB, setTeamB] = useState(""); + const [matchTime, setMatchTime] = useState(nowPlusOneHour()); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + + function validate(): boolean { + const errs: typeof errors = {}; + const trimA = teamA.trim(); + const trimB = teamB.trim(); + + if (!trimA) errs.teamA = "Team A name is required."; + else if (trimA.length > MAX_TEAM_NAME) + errs.teamA = `Team A name must be ${MAX_TEAM_NAME} characters or fewer.`; + + if (!trimB) errs.teamB = "Team B name is required."; + else if (trimB.length > MAX_TEAM_NAME) + errs.teamB = `Team B name must be ${MAX_TEAM_NAME} characters or fewer.`; + + if (trimA && trimB && trimA.toLowerCase() === trimB.toLowerCase()) + errs.form = "Team names must be different."; + + if (!matchTime) { + errs.matchTime = "Match date/time is required."; + } else if (new Date(matchTime) <= new Date()) { + errs.matchTime = "Match time must be in the future."; + } + + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!validate()) return; + + setIsSubmitting(true); + try { + await onAddMatch({ + teamA: teamA.trim(), + teamB: teamB.trim(), + matchTime, + }); + setTeamA(""); + setTeamB(""); + setMatchTime(nowPlusOneHour()); + setErrors({}); + } catch { + setErrors({ form: "Failed to add match. Please try again." }); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+

Add Match

+ + {errors.form && ( +

+ {errors.form} +

+ )} + +
+
+ + setTeamA(e.target.value)} + maxLength={MAX_TEAM_NAME} + placeholder="Team A name" + className="w-full rounded-2xl border border-white/10 bg-slate-950/90 px-4 py-3 text-sm text-white outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-400/20" + /> + {errors.teamA && ( +

{errors.teamA}

+ )} +
+ +
+ + setTeamB(e.target.value)} + maxLength={MAX_TEAM_NAME} + placeholder="Team B name" + className="w-full rounded-2xl border border-white/10 bg-slate-950/90 px-4 py-3 text-sm text-white outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-400/20" + /> + {errors.teamB && ( +

{errors.teamB}

+ )} +
+
+ +
+ + setMatchTime(e.target.value)} + className="w-full rounded-2xl border border-white/10 bg-slate-950/90 px-4 py-3 text-sm text-white outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-400/20" + /> + {errors.matchTime && ( +

{errors.matchTime}

+ )} +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/component/creator-events/BulkMatchUpload.tsx b/frontend/src/component/creator-events/BulkMatchUpload.tsx new file mode 100644 index 00000000..51d62294 --- /dev/null +++ b/frontend/src/component/creator-events/BulkMatchUpload.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useState, useRef } from "react"; +import { Upload, X, Check, AlertCircle, Loader2 } from "lucide-react"; +import { Button } from "@/component/ui/button"; +import type { MatchFormData } from "./AddMatchForm"; + +interface ParsedRow { + teamA: string; + teamB: string; + matchTime: string; + error?: string; +} + +interface BulkMatchUploadProps { + currentMatchCount: number; + maxMatches?: number; + onImport: (matches: MatchFormData[]) => Promise; +} + +function parseCSV(raw: string): ParsedRow[] { + const lines = raw + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + return lines.map((line, idx) => { + const cols = line.split(",").map((c) => c.trim()); + const [teamA = "", teamB = "", matchTime = ""] = cols; + const errors: string[] = []; + + if (!teamA) errors.push("Team A is empty"); + if (!teamB) errors.push("Team B is empty"); + if (teamA && teamB && teamA.toLowerCase() === teamB.toLowerCase()) + errors.push("Team names must be different"); + if (!matchTime) { + errors.push("Match time is missing"); + } else { + const dt = new Date(matchTime); + if (isNaN(dt.getTime())) errors.push("Invalid ISO 8601 date"); + else if (dt <= new Date()) errors.push("Match time must be in the future"); + } + + return { + teamA, + teamB, + matchTime, + error: errors.length > 0 ? errors.join("; ") : undefined, + }; + }); +} + +export default function BulkMatchUpload({ + currentMatchCount, + maxMatches = 100, + onImport, +}: BulkMatchUploadProps) { + const fileInputRef = useRef(null); + const [preview, setPreview] = useState(null); + const [fileName, setFileName] = useState(null); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(null); + const [importSuccess, setImportSuccess] = useState(false); + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + setFileName(file.name); + setImportError(null); + setImportSuccess(false); + + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result as string; + const rows = parseCSV(text); + setPreview(rows); + }; + reader.readAsText(file); + } + + function handleClear() { + setPreview(null); + setFileName(null); + setImportError(null); + setImportSuccess(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + + async function handleImportAll() { + if (!preview) return; + + const valid = preview.filter((r) => !r.error); + if (valid.length === 0) { + setImportError("No valid rows to import."); + return; + } + + const remaining = maxMatches - currentMatchCount; + if (valid.length > remaining) { + setImportError( + `Only ${remaining} more match(es) can be added (limit: ${maxMatches}).`, + ); + return; + } + + setIsImporting(true); + setImportError(null); + + try { + await onImport( + valid.map((r) => ({ + teamA: r.teamA, + teamB: r.teamB, + matchTime: r.matchTime, + })), + ); + setImportSuccess(true); + handleClear(); + } catch { + setImportError("Import failed. Please try again."); + } finally { + setIsImporting(false); + } + } + + const validCount = preview?.filter((r) => !r.error).length ?? 0; + const errorCount = preview?.filter((r) => r.error).length ?? 0; + + return ( +
+
+

Bulk Add Matches

+ {fileName && ( + + )} +
+ +

+ Upload a CSV with columns:{" "} + + Team A, Team B, Match Time (ISO 8601) + +

+ + {!fileName ? ( + + ) : ( +
+ + {fileName} +
+ )} + + {importSuccess && ( +
+ + Matches imported successfully. +
+ )} + + {importError && ( +
+ + {importError} +
+ )} + + {preview && preview.length > 0 && ( +
+
+ ✓ {validCount} valid + {errorCount > 0 && ( + ✗ {errorCount} errors + )} +
+ +
+ + + + + + + + + + + {preview.map((row, idx) => ( + + + + + + + ))} + +
Team ATeam BMatch TimeStatus
+ {row.teamA || } + + {row.teamB || } + + {row.matchTime || } + + {row.error ? ( + + ✗ Error + + ) : ( + ✓ OK + )} +
+
+ +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/component/creator-events/CreateEventForm.tsx b/frontend/src/component/creator-events/CreateEventForm.tsx new file mode 100644 index 00000000..86db5986 --- /dev/null +++ b/frontend/src/component/creator-events/CreateEventForm.tsx @@ -0,0 +1,426 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Check, Copy, Share2, Twitter, ChevronRight, Loader2, AlertCircle } from "lucide-react"; +import { Button } from "@/component/ui/button"; +import { useWallet } from "@/context/WalletContext"; + +type Step = 1 | 2 | 3; + +interface EventDraft { + title: string; + description: string; + maxParticipants: number; +} + +const DRAFT_KEY = "creator_event_draft"; +const MAX_TITLE = 200; +const MAX_DESCRIPTION = 1000; +const MIN_PARTICIPANTS = 2; +const MAX_PARTICIPANTS = 1000; + +function loadDraft(): EventDraft | null { + if (typeof window === "undefined") return null; + try { + const raw = localStorage.getItem(DRAFT_KEY); + return raw ? (JSON.parse(raw) as EventDraft) : null; + } catch { + return null; + } +} + +function saveDraft(draft: EventDraft) { + if (typeof window === "undefined") return; + localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); +} + +function clearDraft() { + if (typeof window === "undefined") return; + localStorage.removeItem(DRAFT_KEY); +} + +export default function CreateEventForm() { + const router = useRouter(); + const { address } = useWallet(); + + const [step, setStep] = useState(1); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [maxParticipants, setMaxParticipants] = useState(100); + const [errors, setErrors] = useState>({}); + const [creationFee, setCreationFee] = useState(null); + const [isSigning, setIsSigning] = useState(false); + const [txError, setTxError] = useState(null); + const [inviteCode, setInviteCode] = useState(""); + const [createdEventId, setCreatedEventId] = useState(""); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const draft = loadDraft(); + if (draft) { + setTitle(draft.title || ""); + setDescription(draft.description || ""); + setMaxParticipants(draft.maxParticipants || 100); + } + }, []); + + useEffect(() => { + if (step === 2 && !creationFee) { + setCreationFee("10.0000000"); + } + }, [step, creationFee]); + + function validateStep1(): boolean { + const errs: typeof errors = {}; + if (!title.trim()) errs.title = "Event title is required."; + else if (title.length > MAX_TITLE) + errs.title = `Title must be ${MAX_TITLE} characters or fewer.`; + if (description.length > MAX_DESCRIPTION) + errs.description = `Description must be ${MAX_DESCRIPTION} characters or fewer.`; + if ( + !Number.isInteger(maxParticipants) || + maxParticipants < MIN_PARTICIPANTS || + maxParticipants > MAX_PARTICIPANTS + ) + errs.maxParticipants = `Participants must be between ${MIN_PARTICIPANTS} and ${MAX_PARTICIPANTS}.`; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + function handleSaveDraft() { + saveDraft({ title, description, maxParticipants }); + alert("Draft saved."); + } + + function handleNextStep() { + if (step === 1 && validateStep1()) { + setStep(2); + } + } + + async function handleCreateEvent() { + setTxError(null); + setIsSigning(true); + try { + await new Promise((r) => setTimeout(r, 1800)); + const code = Math.random().toString(36).toUpperCase().slice(2, 10); + const eventId = `evt-${Date.now()}`; + setInviteCode(code); + setCreatedEventId(eventId); + clearDraft(); + setStep(3); + } catch { + setTxError("Transaction failed. Please try again."); + } finally { + setIsSigning(false); + } + } + + async function handleCopyCode() { + await navigator.clipboard.writeText(inviteCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + const shareUrl = + typeof window !== "undefined" + ? `${window.location.origin}/creator-events/join?code=${inviteCode}` + : ""; + + return ( +
+ {/* Step indicator */} +
+ {([1, 2, 3] as Step[]).map((s, idx) => ( +
+
s + ? "border-amber-400 bg-amber-400 text-slate-950" + : step === s + ? "border-amber-400 bg-transparent text-amber-400" + : "border-white/20 bg-transparent text-slate-500" + }`} + > + {step > s ? : s} +
+ {idx < 2 && ( +
s ? "bg-amber-400" : "bg-white/10"}`} + /> + )} +
+ ))} + + {step === 1 ? "Event Details" : step === 2 ? "Review & Pay" : "Success"} + +
+ + {/* Step 1 */} + {step === 1 && ( +
+

Event Details

+ +
+ + setTitle(e.target.value)} + maxLength={MAX_TITLE} + placeholder="e.g. Apollo Tournament" + className="w-full rounded-2xl border border-white/10 bg-slate-950/90 px-4 py-3 text-sm text-white outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-400/20" + /> +
+ {errors.title && ( +

{errors.title}

+ )} +

+ {title.length}/{MAX_TITLE} +

+
+
+ +
+ +