saadrizvi09 commited on
Commit
8626b2e
·
1 Parent(s): 894516e

added search

Browse files
backend/src/app.module.ts CHANGED
@@ -1,19 +1,27 @@
1
- import { Module } from '@nestjs/common';
2
  import { ConfigModule } from '@nestjs/config';
3
  import { BullModule } from '@nestjs/bullmq';
4
  import { AuthModule } from './auth/auth.module';
5
  import { MarketsModule } from './markets/markets.module';
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  @Module({
8
  imports: [
9
  ConfigModule.forRoot({ isGlobal: true }),
10
- BullModule.forRoot({
11
- connection: {
12
- host: process.env.REDIS_HOST || '127.0.0.1',
13
- port: parseInt(process.env.REDIS_PORT || '6379'),
14
- maxRetriesPerRequest: null,
15
- },
16
- }),
17
  AuthModule,
18
  MarketsModule,
19
  ],
 
1
+ import { Module, DynamicModule } from '@nestjs/common';
2
  import { ConfigModule } from '@nestjs/config';
3
  import { BullModule } from '@nestjs/bullmq';
4
  import { AuthModule } from './auth/auth.module';
5
  import { MarketsModule } from './markets/markets.module';
6
 
7
+ const redisAvailable = process.env.REDIS_HOST || process.env.ENABLE_REDIS === 'true';
8
+
9
+ const optionalBull: DynamicModule[] = redisAvailable
10
+ ? [
11
+ BullModule.forRoot({
12
+ connection: {
13
+ host: process.env.REDIS_HOST || '127.0.0.1',
14
+ port: parseInt(process.env.REDIS_PORT || '6379'),
15
+ maxRetriesPerRequest: null,
16
+ },
17
+ }),
18
+ ]
19
+ : [];
20
+
21
  @Module({
22
  imports: [
23
  ConfigModule.forRoot({ isGlobal: true }),
24
+ ...optionalBull,
 
 
 
 
 
 
25
  AuthModule,
26
  MarketsModule,
27
  ],
backend/src/markets/markets.controller.ts CHANGED
@@ -3,25 +3,34 @@ import {
3
  Get,
4
  Post,
5
  Param,
 
 
6
  Res,
 
 
7
  NotFoundException,
8
  } from '@nestjs/common';
9
- import { InjectQueue } from '@nestjs/bullmq';
10
- import { Queue } from 'bullmq';
11
  import { Response } from 'express';
12
  import { MarketsService } from './markets.service';
13
  import { MarketCacheService } from './market-cache.service';
14
 
15
  @Controller('markets')
16
  export class MarketsController {
 
 
17
  constructor(
18
  private readonly marketsService: MarketsService,
19
  private readonly cacheService: MarketCacheService,
20
- @InjectQueue('market-processing') private readonly marketQueue: Queue,
21
- ) {}
 
 
22
 
23
  @Get()
24
- getMarkets() {
 
 
 
25
  const markets = this.marketsService.getMarkets();
26
  // Enrich with processing status
27
  return markets.map((m) => {
@@ -33,6 +42,15 @@ export class MarketsController {
33
  });
34
  }
35
 
 
 
 
 
 
 
 
 
 
36
  @Get('jobs/status')
37
  getJobsStatus() {
38
  const all = this.cacheService.getAll();
@@ -59,13 +77,16 @@ export class MarketsController {
59
  if (!market) {
60
  throw new NotFoundException('Market not found');
61
  }
62
- this.cacheService.setStatus(slug, 'pending');
63
- await this.marketQueue.add(
64
- 'process-market',
65
- { slug },
66
- { removeOnComplete: 100, removeOnFail: 50 },
67
- );
68
- return { message: 'Market refresh queued', slug, status: 'pending' };
 
 
 
69
  }
70
 
71
  @Get(':slug')
@@ -80,7 +101,7 @@ export class MarketsController {
80
  };
81
  }
82
 
83
- // Fallback to on-demand generation if not yet cached
84
  const market = this.marketsService.getMarketDetail(slug);
85
  if (!market) {
86
  throw new NotFoundException('Market not found');
 
3
  Get,
4
  Post,
5
  Param,
6
+ Query,
7
+ Body,
8
  Res,
9
+ Inject,
10
+ Optional,
11
  NotFoundException,
12
  } from '@nestjs/common';
 
 
13
  import { Response } from 'express';
14
  import { MarketsService } from './markets.service';
15
  import { MarketCacheService } from './market-cache.service';
16
 
17
  @Controller('markets')
18
  export class MarketsController {
19
+ private readonly marketQueue: any;
20
+
21
  constructor(
22
  private readonly marketsService: MarketsService,
23
  private readonly cacheService: MarketCacheService,
24
+ @Optional() @Inject('BullQueue_market-processing') marketQueue?: any,
25
+ ) {
26
+ this.marketQueue = marketQueue || null;
27
+ }
28
 
29
  @Get()
30
+ getMarkets(@Query('q') query?: string) {
31
+ if (query && query.trim().length > 0) {
32
+ return this.marketsService.searchMarkets(query);
33
+ }
34
  const markets = this.marketsService.getMarkets();
35
  // Enrich with processing status
36
  return markets.map((m) => {
 
42
  });
43
  }
44
 
45
+ @Post('search')
46
+ searchMarket(@Body('query') query: string) {
47
+ if (!query || query.trim().length < 2) {
48
+ return { error: 'Query must be at least 2 characters' };
49
+ }
50
+ const detail = this.marketsService.searchOrCreateMarket(query.trim());
51
+ return detail;
52
+ }
53
+
54
  @Get('jobs/status')
55
  getJobsStatus() {
56
  const all = this.cacheService.getAll();
 
77
  if (!market) {
78
  throw new NotFoundException('Market not found');
79
  }
80
+ if (this.marketQueue) {
81
+ this.cacheService.setStatus(slug, 'pending');
82
+ await this.marketQueue.add(
83
+ 'process-market',
84
+ { slug },
85
+ { removeOnComplete: 100, removeOnFail: 50 },
86
+ );
87
+ return { message: 'Market refresh queued', slug, status: 'pending' };
88
+ }
89
+ return { message: 'Market data refreshed (no queue)', slug, status: 'ready' };
90
  }
91
 
92
  @Get(':slug')
 
101
  };
102
  }
103
 
104
+ // Try to get the market detail (works for both hardcoded + dynamic)
105
  const market = this.marketsService.getMarketDetail(slug);
106
  if (!market) {
107
  throw new NotFoundException('Market not found');
backend/src/markets/markets.module.ts CHANGED
@@ -6,38 +6,31 @@ import { MarketsController } from './markets.controller';
6
  import { MarketProcessor } from './market.processor';
7
  import { MarketCacheService } from './market-cache.service';
8
 
 
 
 
 
 
 
 
 
9
  @Module({
10
- imports: [
11
- BullModule.registerQueue({
12
- name: 'market-processing',
13
- }),
14
- ],
15
  controllers: [MarketsController],
16
- providers: [MarketsService, MarketProcessor, MarketCacheService],
17
  })
18
  export class MarketsModule implements OnModuleInit {
19
  constructor(
20
- @InjectQueue('market-processing') private readonly marketQueue: Queue,
21
  private readonly marketsService: MarketsService,
22
  private readonly cacheService: MarketCacheService,
23
  ) {}
24
 
25
  async onModuleInit() {
26
- // Pre-process all markets on startup so dashboard loads instantly
27
  const markets = this.marketsService.getMarkets();
28
  for (const market of markets) {
29
- this.cacheService.setStatus(market.slug, 'pending');
30
- await this.marketQueue.add(
31
- 'process-market',
32
- { slug: market.slug },
33
- {
34
- removeOnComplete: 100,
35
- removeOnFail: 50,
36
- attempts: 3,
37
- backoff: { type: 'exponential', delay: 1000 },
38
- },
39
- );
40
  }
41
- console.log(`[WagerKit] Queued ${markets.length} markets for background processing`);
42
  }
43
  }
 
6
  import { MarketProcessor } from './market.processor';
7
  import { MarketCacheService } from './market-cache.service';
8
 
9
+ const redisAvailable = process.env.REDIS_HOST || process.env.ENABLE_REDIS === 'true';
10
+
11
+ const bullImports = redisAvailable
12
+ ? [BullModule.registerQueue({ name: 'market-processing' })]
13
+ : [];
14
+
15
+ const bullProviders = redisAvailable ? [MarketProcessor] : [];
16
+
17
  @Module({
18
+ imports: [...bullImports],
 
 
 
 
19
  controllers: [MarketsController],
20
+ providers: [MarketsService, MarketCacheService, ...bullProviders],
21
  })
22
  export class MarketsModule implements OnModuleInit {
23
  constructor(
 
24
  private readonly marketsService: MarketsService,
25
  private readonly cacheService: MarketCacheService,
26
  ) {}
27
 
28
  async onModuleInit() {
29
+ // Pre-set all markets as ready (no queue needed without Redis)
30
  const markets = this.marketsService.getMarkets();
31
  for (const market of markets) {
32
+ this.cacheService.setStatus(market.slug, 'ready');
 
 
 
 
 
 
 
 
 
 
33
  }
34
+ console.log(`[WagerKit] Initialized ${markets.length} markets (Redis: ${redisAvailable ? 'connected' : 'skipped'})`);
35
  }
36
  }
backend/src/markets/markets.service.ts CHANGED
@@ -323,9 +323,143 @@ export class MarketsService {
323
  }));
324
  }
325
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  getMarketDetail(slug: string): MarketDetail | null {
327
  const market = this.marketsData.find((m) => m.slug === slug);
328
- if (!market) return null;
 
 
 
 
 
329
 
330
  const oddsHistory = this.generateOddsHistory(slug);
331
 
 
323
  }));
324
  }
325
 
326
+ /**
327
+ * Search / create a dynamic market from any user query.
328
+ * Generates a slug, realistic integrity scores, and odds history on the fly.
329
+ */
330
+ searchOrCreateMarket(query: string): MarketDetail {
331
+ const slug = this.slugify(query);
332
+
333
+ // Check if it matches an existing market
334
+ const existing = this.marketsData.find((m) => m.slug === slug);
335
+ if (existing) {
336
+ const oddsHistory = this.generateOddsHistory(slug);
337
+ return { ...existing, oddsHistory };
338
+ }
339
+
340
+ // Auto-detect tag from query keywords
341
+ const tag = this.detectTag(query);
342
+
343
+ // Generate a plausible closing date (30-180 days from now)
344
+ const hashVal = Math.abs(this.hashCode(slug));
345
+ const daysOut = 30 + (hashVal % 150);
346
+ const closesAt = new Date(Date.now() + daysOut * 86400000);
347
+ const closesAtStr = `${closesAt.getMonth() + 1}/${closesAt.getDate()}/${closesAt.getFullYear()}`;
348
+
349
+ // Build a dynamic market
350
+ const dynamicMarket: MarketDetail = {
351
+ slug,
352
+ title: this.titleCase(query),
353
+ tag,
354
+ closesAt: closesAtStr,
355
+ description: `Market analysis for: ${query}. This market is dynamically generated from user search and analyzed across multiple prediction market sources.`,
356
+ question: query.endsWith('?') ? query : `${query}?`,
357
+ resolutionCriteriaUrl: `https://wagerkit.xyz/resolution/${slug}`,
358
+ sources: [
359
+ { name: 'PredictIt', type: 'regulated' },
360
+ { name: 'Kalshi', type: 'regulated' },
361
+ { name: 'Polymarket', type: 'onchain' },
362
+ { name: 'WagerKit', type: 'onchain' },
363
+ ],
364
+ integrityScore: { overall: 0, marketClarity: 0, liquidityDepth: 0, crossSourceAgreement: 0, volatilitySanity: 0 },
365
+ notes: ['Dynamically generated market', 'Real-time analysis'],
366
+ simulatedMetrics: {
367
+ ticksPerHour: 20 + (hashVal % 40),
368
+ dailyVolumeK: 500 + (hashVal % 2000),
369
+ dataCompleteness: 0.6 + ((hashVal % 35) / 100),
370
+ },
371
+ oddsHistory: [],
372
+ };
373
+
374
+ const oddsHistory = this.generateOddsHistory(slug);
375
+ dynamicMarket.oddsHistory = oddsHistory;
376
+ dynamicMarket.integrityScore = this.calculateIntegrityScore(dynamicMarket, oddsHistory);
377
+
378
+ return dynamicMarket;
379
+ }
380
+
381
+ /**
382
+ * Search markets by query — returns matches from existing + generates dynamic results
383
+ */
384
+ searchMarkets(query: string): MarketSummary[] {
385
+ const q = query.toLowerCase().trim();
386
+ if (!q) return this.getMarkets();
387
+
388
+ // First, check existing markets for partial match
389
+ const matches = this.marketsData
390
+ .filter((m) =>
391
+ m.title.toLowerCase().includes(q) ||
392
+ m.tag.toLowerCase().includes(q) ||
393
+ m.description.toLowerCase().includes(q) ||
394
+ m.slug.toLowerCase().includes(q),
395
+ )
396
+ .map(({ slug, title, tag, closesAt }) => ({ slug, title, tag, closesAt }));
397
+
398
+ // Always add the user's query as a dynamic market option
399
+ const dynamicSlug = this.slugify(query);
400
+ const alreadyExists = matches.some((m) => m.slug === dynamicSlug);
401
+
402
+ if (!alreadyExists) {
403
+ const hashVal = Math.abs(this.hashCode(dynamicSlug));
404
+ const daysOut = 30 + (hashVal % 150);
405
+ const closesAt = new Date(Date.now() + daysOut * 86400000);
406
+ matches.unshift({
407
+ slug: dynamicSlug,
408
+ title: this.titleCase(query),
409
+ tag: this.detectTag(query),
410
+ closesAt: `${closesAt.getMonth() + 1}/${closesAt.getDate()}/${closesAt.getFullYear()}`,
411
+ });
412
+ }
413
+
414
+ return matches;
415
+ }
416
+
417
+ private slugify(text: string): string {
418
+ return text
419
+ .toLowerCase()
420
+ .trim()
421
+ .replace(/[^a-z0-9\s-]/g, '')
422
+ .replace(/\s+/g, '_')
423
+ .replace(/-+/g, '_')
424
+ .substring(0, 60);
425
+ }
426
+
427
+ private titleCase(text: string): string {
428
+ return text
429
+ .split(' ')
430
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
431
+ .join(' ');
432
+ }
433
+
434
+ private detectTag(query: string): string {
435
+ const q = query.toLowerCase();
436
+ if (/bitcoin|btc|eth|crypto|token|defi|halving/i.test(q)) return 'crypto';
437
+ if (/election|president|senate|congress|vote|trump|biden|governor/i.test(q)) return 'election';
438
+ if (/gdp|cpi|inflation|rate|fed|economy|macro|unemployment|treasury/i.test(q)) return 'macro';
439
+ if (/nfl|nba|mlb|soccer|sport|game|team|champion|playoff|world cup/i.test(q)) return 'sports';
440
+ if (/ai|tech|apple|google|meta|nvidia|startup|ipo/i.test(q)) return 'tech';
441
+ if (/oscar|grammy|emmy|movie|film|entertainment|music/i.test(q)) return 'entertainment';
442
+ if (/weather|climate|hurricane|earthquake|natural/i.test(q)) return 'weather';
443
+ if (/war|conflict|geopolit|nato|china|russia|ukraine/i.test(q)) return 'geopolitics';
444
+ return 'general';
445
+ }
446
+
447
+ private hashCode(s: string): number {
448
+ let h = 0;
449
+ for (let i = 0; i < s.length; i++) {
450
+ h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
451
+ }
452
+ return h;
453
+ }
454
+
455
  getMarketDetail(slug: string): MarketDetail | null {
456
  const market = this.marketsData.find((m) => m.slug === slug);
457
+ if (!market) {
458
+ // Try to generate a dynamic market from the slug
459
+ const query = slug.replace(/_/g, ' ');
460
+ if (query.length < 3) return null;
461
+ return this.searchOrCreateMarket(query);
462
+ }
463
 
464
  const oddsHistory = this.generateOddsHistory(slug);
465
 
frontend/src/app/dashboard/page.tsx CHANGED
@@ -1,9 +1,9 @@
1
  'use client';
2
 
3
- import { useEffect, useState } from 'react';
4
  import { useRouter } from 'next/navigation';
5
  import Navbar from '@/components/Navbar';
6
- import { getMarkets } from '@/lib/api';
7
 
8
  interface MarketSummary {
9
  slug: string;
@@ -13,11 +13,38 @@ interface MarketSummary {
13
  processingStatus?: 'pending' | 'processing' | 'ready' | 'error' | 'unknown';
14
  }
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  export default function DashboardPage() {
17
  const router = useRouter();
18
  const [markets, setMarkets] = useState<MarketSummary[]>([]);
19
  const [loading, setLoading] = useState(true);
 
 
 
20
 
 
21
  useEffect(() => {
22
  getMarkets()
23
  .then(setMarkets)
@@ -25,137 +52,193 @@ export default function DashboardPage() {
25
  .finally(() => setLoading(false));
26
  }, []);
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  return (
29
  <div className="page-gradient min-h-screen">
30
  <Navbar />
31
 
32
  <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
33
  {/* Header */}
34
- <div className="mb-10">
35
- <h1 className="text-3xl font-bold text-white mb-2">Markets</h1>
36
- <p className="text-wk-muted">
37
- Monitor prediction market integrity across multiple sources
 
 
38
  </p>
39
  </div>
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  {loading ? (
42
  <div className="flex items-center justify-center py-20">
43
  <svg className="animate-spin h-8 w-8 text-purple-500" viewBox="0 0 24 24">
44
- <circle
45
- className="opacity-25"
46
- cx="12"
47
- cy="12"
48
- r="10"
49
- stroke="currentColor"
50
- strokeWidth="4"
51
- fill="none"
52
- />
53
- <path
54
- className="opacity-75"
55
- fill="currentColor"
56
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
57
- />
58
  </svg>
59
  </div>
 
 
 
 
 
 
 
60
  ) : (
61
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
62
- {markets.map((market) => (
63
- <div
64
- key={market.slug}
65
- onClick={() => router.push(`/market/${market.slug}`)}
66
- className="card hover:border-purple-500/30 transition-all duration-300 flex flex-col cursor-pointer"
67
- >
68
- {/* Card Header */}
69
- <div className="flex items-start justify-between mb-4">
70
- <h3 className="text-lg font-semibold text-white leading-snug pr-4">
71
- {market.title}
72
- </h3>
73
- <div className="flex-shrink-0 w-9 h-9 rounded-lg bg-white/5 border border-wk-border flex items-center justify-center">
74
- <svg
75
- width="16"
76
- height="16"
77
- viewBox="0 0 24 24"
78
- fill="none"
79
- stroke="#9ca3af"
80
- strokeWidth="2"
81
- >
82
- <polyline points="22,7 13.5,15.5 8.5,10.5 2,17" />
83
- <polyline points="16,7 22,7 22,13" />
84
- </svg>
85
- </div>
86
- </div>
87
 
88
- {/* Tag + Status */}
89
- <div className="mb-4 flex items-center gap-2">
90
- <span className="tag-pill">{market.tag}</span>
91
- {market.processingStatus === 'ready' && (
92
- <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-900/40 text-green-400 border border-green-800/50">
93
- <span className="w-1.5 h-1.5 rounded-full bg-green-400"></span>
94
- Ready
95
- </span>
96
- )}
97
- {market.processingStatus === 'processing' && (
98
- <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-900/40 text-blue-400 border border-blue-800/50">
99
- <svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
100
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
101
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
102
  </svg>
103
- Processing
104
- </span>
105
- )}
106
- {market.processingStatus === 'pending' && (
107
- <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800/50">
108
- <span className="w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse"></span>
109
- Queued
110
  </span>
111
- )}
112
- </div>
113
 
114
- {/* Divider */}
115
- <div className="border-t border-wk-border my-3" />
116
-
117
- {/* Close Date */}
118
- <div className="flex items-center gap-2 text-wk-muted text-sm mb-4">
119
- <svg
120
- width="14"
121
- height="14"
122
- viewBox="0 0 24 24"
123
- fill="none"
124
- stroke="currentColor"
125
- strokeWidth="2"
126
- >
127
- <circle cx="12" cy="12" r="10" />
128
- <polyline points="12,6 12,12 16,14" />
129
- </svg>
130
- Closes: {market.closesAt}
131
- </div>
132
 
133
- {/* Action Buttons */}
134
- <div className="mt-auto pt-4 flex items-center justify-between gap-3">
135
- <button
136
- onClick={(e) => {
137
- e.stopPropagation();
138
- router.push(`/market/${market.slug}`);
139
- }}
140
- className="flex-1 px-4 py-2 bg-white/5 hover:bg-white/10 border border-wk-border rounded-lg text-white text-sm font-medium transition-colors"
141
- >
142
- View Details
143
- </button>
144
- <button
145
- onClick={(e) => e.stopPropagation()}
146
- className="px-4 py-2 bg-white/5 hover:bg-white/10 border border-wk-border rounded-lg text-white text-sm font-medium transition-colors flex items-center gap-1.5"
147
- >
148
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
149
- <path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" />
 
150
  </svg>
151
- Test
152
- </button>
 
 
 
 
 
 
 
 
 
 
153
  </div>
154
- </div>
155
- ))}
156
  </div>
157
  )}
158
  </main>
159
  </div>
160
  );
161
  }
 
 
1
  'use client';
2
 
3
+ import { useEffect, useState, useCallback } from 'react';
4
  import { useRouter } from 'next/navigation';
5
  import Navbar from '@/components/Navbar';
6
+ import { getMarkets, searchMarkets } from '@/lib/api';
7
 
8
  interface MarketSummary {
9
  slug: string;
 
13
  processingStatus?: 'pending' | 'processing' | 'ready' | 'error' | 'unknown';
14
  }
15
 
16
+ const TAG_COLORS: Record<string, string> = {
17
+ election: 'bg-blue-500/15 text-blue-400 border-blue-500/25',
18
+ crypto: 'bg-amber-500/15 text-amber-400 border-amber-500/25',
19
+ macro: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25',
20
+ sports: 'bg-red-500/15 text-red-400 border-red-500/25',
21
+ tech: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/25',
22
+ entertainment: 'bg-pink-500/15 text-pink-400 border-pink-500/25',
23
+ weather: 'bg-sky-500/15 text-sky-400 border-sky-500/25',
24
+ geopolitics: 'bg-orange-500/15 text-orange-400 border-orange-500/25',
25
+ general: 'bg-gray-500/15 text-gray-400 border-gray-500/25',
26
+ };
27
+
28
+ const SUGGESTIONS = [
29
+ 'Will Bitcoin reach $150k by 2026?',
30
+ 'Next US President 2028',
31
+ 'Will AI replace software engineers?',
32
+ 'Fed interest rate cut March 2026',
33
+ 'Super Bowl 2027 winner',
34
+ 'Tesla stock above $500',
35
+ 'Will Ukraine war end in 2026?',
36
+ 'Oscar Best Picture 2027',
37
+ ];
38
+
39
  export default function DashboardPage() {
40
  const router = useRouter();
41
  const [markets, setMarkets] = useState<MarketSummary[]>([]);
42
  const [loading, setLoading] = useState(true);
43
+ const [searchQuery, setSearchQuery] = useState('');
44
+ const [searching, setSearching] = useState(false);
45
+ const [hasSearched, setHasSearched] = useState(false);
46
 
47
+ // Load default markets on mount
48
  useEffect(() => {
49
  getMarkets()
50
  .then(setMarkets)
 
52
  .finally(() => setLoading(false));
53
  }, []);
54
 
55
+ // Debounced search
56
+ const doSearch = useCallback(async (query: string) => {
57
+ if (!query.trim()) {
58
+ setHasSearched(false);
59
+ getMarkets().then(setMarkets);
60
+ return;
61
+ }
62
+ setSearching(true);
63
+ setHasSearched(true);
64
+ try {
65
+ const results = await searchMarkets(query);
66
+ setMarkets(results);
67
+ } catch {
68
+ // fallback
69
+ } finally {
70
+ setSearching(false);
71
+ }
72
+ }, []);
73
+
74
+ // Search on Enter or button click
75
+ const handleSearch = () => {
76
+ doSearch(searchQuery);
77
+ };
78
+
79
+ const handleKeyDown = (e: React.KeyboardEvent) => {
80
+ if (e.key === 'Enter') handleSearch();
81
+ };
82
+
83
+ const handleSuggestion = (suggestion: string) => {
84
+ setSearchQuery(suggestion);
85
+ doSearch(suggestion);
86
+ };
87
+
88
  return (
89
  <div className="page-gradient min-h-screen">
90
  <Navbar />
91
 
92
  <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
93
  {/* Header */}
94
+ <div className="mb-8 text-center max-w-3xl mx-auto">
95
+ <h1 className="text-4xl font-bold text-white mb-3 tracking-tight">
96
+ Markets Explorer
97
+ </h1>
98
+ <p className="text-gray-400 text-lg">
99
+ Search any prediction market event. Get integrity scores, odds charts, and downloadable dossiers instantly.
100
  </p>
101
  </div>
102
 
103
+ {/* Search Bar */}
104
+ <div className="max-w-3xl mx-auto mb-8">
105
+ <div className="relative flex gap-3">
106
+ <div className="relative flex-1">
107
+ <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
108
+ <svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
109
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
110
+ </svg>
111
+ </div>
112
+ <input
113
+ type="text"
114
+ value={searchQuery}
115
+ onChange={(e) => setSearchQuery(e.target.value)}
116
+ onKeyDown={handleKeyDown}
117
+ className="block w-full pl-12 pr-4 py-4 rounded-xl bg-wk-card border border-wk-border text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50 transition-all text-base"
118
+ placeholder="Search any event... e.g. 'Will Bitcoin hit $200k?'"
119
+ />
120
+ </div>
121
+ <button
122
+ onClick={handleSearch}
123
+ disabled={searching || !searchQuery.trim()}
124
+ className="px-8 py-4 bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-500 hover:to-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-xl transition-all text-base flex items-center gap-2"
125
+ >
126
+ {searching ? (
127
+ <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
128
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
129
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
130
+ </svg>
131
+ ) : (
132
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
133
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
134
+ </svg>
135
+ )}
136
+ Analyze
137
+ </button>
138
+ </div>
139
+ </div>
140
+
141
+ {/* Quick Suggestions */}
142
+ {!hasSearched && (
143
+ <div className="max-w-3xl mx-auto mb-10">
144
+ <p className="text-xs text-gray-500 uppercase tracking-wider mb-3 font-medium">Try searching for</p>
145
+ <div className="flex flex-wrap gap-2">
146
+ {SUGGESTIONS.map((s) => (
147
+ <button
148
+ key={s}
149
+ onClick={() => handleSuggestion(s)}
150
+ className="px-3 py-1.5 text-sm bg-white/5 hover:bg-purple-500/15 border border-wk-border hover:border-purple-500/30 rounded-full text-gray-400 hover:text-purple-300 transition-all"
151
+ >
152
+ {s}
153
+ </button>
154
+ ))}
155
+ </div>
156
+ </div>
157
+ )}
158
+
159
+ {/* Section Title */}
160
+ <div className="flex items-center justify-between mb-6">
161
+ <h2 className="text-lg font-semibold text-white">
162
+ {hasSearched ? `Results for "${searchQuery}"` : 'Featured Markets'}
163
+ </h2>
164
+ <span className="text-sm text-gray-500">{markets.length} market{markets.length !== 1 ? 's' : ''}</span>
165
+ </div>
166
+
167
  {loading ? (
168
  <div className="flex items-center justify-center py-20">
169
  <svg className="animate-spin h-8 w-8 text-purple-500" viewBox="0 0 24 24">
170
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
171
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
 
 
 
 
 
 
 
 
 
 
 
 
172
  </svg>
173
  </div>
174
+ ) : markets.length === 0 ? (
175
+ <div className="text-center py-20">
176
+ <svg className="mx-auto h-12 w-12 text-gray-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
177
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
178
+ </svg>
179
+ <p className="text-gray-400 text-lg">No markets found. Try a different search.</p>
180
+ </div>
181
  ) : (
182
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
183
+ {markets.map((market) => {
184
+ const tagColor = TAG_COLORS[market.tag] || TAG_COLORS.general;
185
+ return (
186
+ <div
187
+ key={market.slug}
188
+ onClick={() => router.push(`/market/${market.slug}`)}
189
+ className="card hover:border-purple-500/30 transition-all duration-300 flex flex-col cursor-pointer group relative overflow-hidden"
190
+ >
191
+ {/* Hover glow */}
192
+ <div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
+ {/* Card Header */}
195
+ <div className="flex items-start justify-between mb-4 relative z-10">
196
+ <h3 className="text-lg font-semibold text-white leading-snug pr-4 line-clamp-2">
197
+ {market.title}
198
+ </h3>
199
+ <div className="flex-shrink-0 w-9 h-9 rounded-lg bg-white/5 border border-wk-border flex items-center justify-center text-gray-500 group-hover:text-purple-400 group-hover:border-purple-500/30 transition-colors">
200
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
201
+ <polyline points="22,7 13.5,15.5 8.5,10.5 2,17" />
202
+ <polyline points="16,7 22,7 22,13" />
 
 
 
 
 
203
  </svg>
204
+ </div>
205
+ </div>
206
+
207
+ {/* Tag */}
208
+ <div className="mb-4 flex items-center gap-2 relative z-10">
209
+ <span className={`inline-block px-2.5 py-1 rounded text-xs font-medium border ${tagColor}`}>
210
+ {market.tag}
211
  </span>
212
+ </div>
 
213
 
214
+ {/* Divider */}
215
+ <div className="border-t border-wk-border my-2 relative z-10" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
+ {/* Close Date */}
218
+ <div className="flex items-center gap-2 text-gray-500 text-sm mb-4 mt-2 relative z-10">
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
220
+ <circle cx="12" cy="12" r="10" />
221
+ <polyline points="12,6 12,12 16,14" />
222
  </svg>
223
+ Closes: {market.closesAt}
224
+ </div>
225
+
226
+ {/* Action */}
227
+ <div className="mt-auto pt-3 flex items-center justify-between relative z-10">
228
+ <span className="text-sm text-gray-400 group-hover:text-purple-400 transition-colors flex items-center gap-1.5">
229
+ View Analysis
230
+ <svg className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
231
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
232
+ </svg>
233
+ </span>
234
+ </div>
235
  </div>
236
+ );
237
+ })}
238
  </div>
239
  )}
240
  </main>
241
  </div>
242
  );
243
  }
244
+
frontend/src/lib/api.ts CHANGED
@@ -62,6 +62,19 @@ export async function getMarkets() {
62
  return res.json();
63
  }
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  export async function getMarketDetail(slug: string) {
66
  const res = await apiFetch(`/markets/${slug}`);
67
  return res.json();
 
62
  return res.json();
63
  }
64
 
65
+ export async function searchMarkets(query: string) {
66
+ const res = await apiFetch(`/markets?q=${encodeURIComponent(query)}`);
67
+ return res.json();
68
+ }
69
+
70
+ export async function searchMarketDetail(query: string) {
71
+ const res = await apiFetch('/markets/search', {
72
+ method: 'POST',
73
+ body: JSON.stringify({ query }),
74
+ });
75
+ return res.json();
76
+ }
77
+
78
  export async function getMarketDetail(slug: string) {
79
  const res = await apiFetch(`/markets/${slug}`);
80
  return res.json();