FairValue commited on
Commit
7dff677
·
1 Parent(s): 1f734ca

feat: implement professional PDF negotiation reports and terminology refinement

Browse files
.gitignore CHANGED
@@ -29,3 +29,6 @@ Thumbs.db
29
  .vscode/
30
  .idea/
31
  *.swp
 
 
 
 
29
  .vscode/
30
  .idea/
31
  *.swp
32
+
33
+ .vercel
34
+ access_codes.csv
.vercelignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /.git
2
+ /.vscode
3
+ /data
4
+ /scratch
5
+ /src
6
+ /__pycache__
7
+ /fairvalue_xgboost.json
8
+ *.py
9
+ /README.md
10
+ /README2.md
11
+ /pitch_deck.md
12
+ /tech_stack.md
13
+ /data_schema.md
14
+ /Dockerfile
15
+ /render.yaml
16
+ /requirements.txt
17
+ /error.log
18
+ /api
FairValue High-Conversion Outreach.txt ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FairValue: High-Conversion Outreach Sequence
2
+ Strategy: Transformation over Features
3
+ The Problem: Clubs overpay by 20-30% due to "hype" and agent pressure.
4
+ The Transformation: Data-backed confidence that saves £10M+ per window and guarantees PSR safety.
5
+ Email 1: The "Intrinsic Value" Hook
6
+ Subject: Eliminating overpayment on [Target Player Name]
7
+
8
+ Hi [Name],
9
+
10
+ Most clubs enter the summer window knowing exactly what a player is listed for, but few know what they are actually worth.
11
+
12
+ We’ve built FairValue to solve the £20M "hype gap." By isolating pure performance metrics from market noise, we provide Directors of Football with a mathematical ceiling for any target.
13
+
14
+ In an era of strict PSR compliance, being wrong by £10M isn't just a mistake—it's a multi-year recruitment handicap.
15
+
16
+ Do you have 10 minutes next Tuesday to see how we’re quantifying intrinsic talent for Premier League scouting departments?
17
+
18
+ Best,
19
+
20
+ [Your Name]
21
+
22
+ Email 2: The Follow-Up (The Risk)
23
+ Subject: The cost of the "Eye Test" alone
24
+
25
+ Hi [Name],
26
+
27
+ Following up on my previous note.
28
+
29
+ The "eye test" tells you if a player can play. FairValue tells you if they fit your 5-year financial model.
30
+
31
+ We recently modeled a high-profile PL move where the club overpaid by £18M purely on "sentiment." That’s a whole second squad player lost to market noise.
32
+
33
+ We help you benchmark the depreciation hit of age and contract length before the first bid is even made.
34
+
35
+ Worth a quick look?
36
+
37
+ Best,
38
+
39
+ [Your Name]
40
+
41
+ Email 3: The Follow-Up (Negotiation Leverage)
42
+ Subject: Bringing math to the table
43
+
44
+ Hi [Name],
45
+
46
+ When agents drive the price up, what is your counter-leverage?
47
+
48
+ Our partners use FairValue's Market Impact Reports as a defensive wall in negotiations. Having an auditable, performance-based "Hard Cap" changes the power dynamic in the transfer room.
49
+
50
+ It’s not just about spending less; it’s about spending precisely.
51
+
52
+ Happy to share a sample valuation report for a current PL target if you're interested.
53
+
54
+ Best,
55
+
56
+ [Your Name]
57
+
58
+ Email 4: The Follow-Up (PSR Safeguard)
59
+ Subject: Safeguarding your 2025/26 budget
60
+
61
+ Hi [Name],
62
+
63
+ With the latest PSR rulings, the margin for error has vanished.
64
+
65
+ FairValue doesn't just estimate prices; it models the amortisation hit and "Intrinsic Value" of your targets to ensure every signing is a financial asset, not a liability.
66
+
67
+ If you're currently profiling targets for the upcoming window, I'd love to show you how we're de-risking acquisitions for elite investment groups and DOFs.
68
+
69
+ Best,
70
+
71
+ [Your Name]
72
+
73
+ Email 5: The Final Call (Low Friction)
74
+ Subject: One last thing, [Name]
75
+
76
+ Hi [Name],
77
+
78
+ I'll stop crowding your inbox after this.
79
+
80
+ I know how busy the recruitment cycle is. If you're not looking to overhaul your valuation methodology right now, I completely understand.
81
+
82
+ However, if you'd like a "second opinion" on a single high-stakes target you're tracking, I'm happy to run a one-off evaluation for you—no strings attached.
83
+
84
+ If not, I'll check back in after the window closes.
85
+
86
+ Best,
87
+
88
+ [Your Name]
README.md CHANGED
@@ -3,10 +3,7 @@ title: FairValue Transfer Cap Estimator
3
  emoji: ⚽
4
  colorFrom: green
5
  colorTo: gray
6
- sdk: streamlit
7
- sdk_version: 1.32.0
8
- python_version: 3.11
9
- app_file: app.py
10
  pinned: true
11
  license: mit
12
  ---
 
3
  emoji: ⚽
4
  colorFrom: green
5
  colorTo: gray
6
+ sdk: docker
 
 
 
7
  pinned: true
8
  license: mit
9
  ---
api/main.py CHANGED
@@ -8,6 +8,7 @@ import time
8
  import xgboost as xgb
9
  from ddgs import DDGS
10
  from textblob import TextBlob
 
11
 
12
  app = FastAPI(
13
  title="FairValue Strategic AI API",
@@ -24,6 +25,51 @@ app.add_middleware(
24
  allow_headers=["*"],
25
  )
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  @app.get("/")
29
  def health_check():
 
8
  import xgboost as xgb
9
  from ddgs import DDGS
10
  from textblob import TextBlob
11
+ import pathlib
12
 
13
  app = FastAPI(
14
  title="FairValue Strategic AI API",
 
25
  allow_headers=["*"],
26
  )
27
 
28
+ ACCESS_CODES_PATH = pathlib.Path(__file__).parent / "access_codes.csv"
29
+
30
+ class CodeRequest(BaseModel):
31
+ code: str
32
+
33
+ @app.post("/api/validate-code")
34
+ async def validate_code(req: CodeRequest):
35
+ """
36
+ Validates a secret access code against the local CSV database.
37
+ Each code is restricted to a maximum of 15 uses.
38
+ """
39
+ # Always allow master bypass code
40
+ if req.code == "FairValue-103":
41
+ return {"status": "success", "message": "Master bypass active"}
42
+
43
+ if not os.path.exists(ACCESS_CODES_PATH):
44
+ raise HTTPException(status_code=500, detail="Access database unavailable")
45
+
46
+ try:
47
+ df = pd.read_csv(ACCESS_CODES_PATH)
48
+ df['code'] = df['code'].astype(str)
49
+
50
+ if req.code not in df['code'].values:
51
+ raise HTTPException(status_code=403, detail="Invalid access code")
52
+
53
+ row_idx = df.index[df['code'] == req.code].tolist()[0]
54
+ current_uses = int(df.at[row_idx, 'uses'])
55
+
56
+ if current_uses >= 15:
57
+ raise HTTPException(status_code=403, detail="Access code expired (max 15 uses reached)")
58
+
59
+ # Increment and persist
60
+ df.at[row_idx, 'uses'] = current_uses + 1
61
+ df.to_csv(ACCESS_CODES_PATH, index=False)
62
+
63
+ return {
64
+ "status": "success",
65
+ "uses_remaining": 15 - (current_uses + 1)
66
+ }
67
+ except HTTPException:
68
+ raise
69
+ except Exception as e:
70
+ raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
71
+
72
+
73
 
74
  @app.get("/")
75
  def health_check():
fairvalue-webapp/.gitignore CHANGED
@@ -22,3 +22,5 @@ dist-ssr
22
  *.njsproj
23
  *.sln
24
  *.sw?
 
 
 
22
  *.njsproj
23
  *.sln
24
  *.sw?
25
+
26
+ .vercel
fairvalue-webapp/package-lock.json CHANGED
@@ -8,6 +8,10 @@
8
  "name": "fairvalue-webapp",
9
  "version": "1.0.0",
10
  "dependencies": {
 
 
 
 
11
  "react": "^18.3.1",
12
  "react-dom": "^18.3.1",
13
  "react-router-dom": "^6.23.1",
@@ -1234,6 +1238,12 @@
1234
  "dev": true,
1235
  "license": "MIT"
1236
  },
 
 
 
 
 
 
1237
  "node_modules/@types/prop-types": {
1238
  "version": "15.7.15",
1239
  "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1241,6 +1251,13 @@
1241
  "dev": true,
1242
  "license": "MIT"
1243
  },
 
 
 
 
 
 
 
1244
  "node_modules/@types/react": {
1245
  "version": "18.3.28",
1246
  "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
@@ -1262,6 +1279,13 @@
1262
  "@types/react": "^18.0.0"
1263
  }
1264
  },
 
 
 
 
 
 
 
1265
  "node_modules/@vitejs/plugin-react": {
1266
  "version": "4.7.0",
1267
  "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1283,6 +1307,15 @@
1283
  "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1284
  }
1285
  },
 
 
 
 
 
 
 
 
 
1286
  "node_modules/baseline-browser-mapping": {
1287
  "version": "2.10.21",
1288
  "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
@@ -1351,6 +1384,26 @@
1351
  ],
1352
  "license": "CC-BY-4.0"
1353
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1354
  "node_modules/clsx": {
1355
  "version": "2.1.1",
1356
  "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1367,6 +1420,27 @@
1367
  "dev": true,
1368
  "license": "MIT"
1369
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1370
  "node_modules/csstype": {
1371
  "version": "3.2.3",
1372
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1528,6 +1602,16 @@
1528
  "csstype": "^3.0.2"
1529
  }
1530
  },
 
 
 
 
 
 
 
 
 
 
1531
  "node_modules/electron-to-chromium": {
1532
  "version": "1.5.343",
1533
  "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz",
@@ -1599,6 +1683,50 @@
1599
  "node": ">=6.0.0"
1600
  }
1601
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1602
  "node_modules/fsevents": {
1603
  "version": "2.3.3",
1604
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1624,6 +1752,19 @@
1624
  "node": ">=6.9.0"
1625
  }
1626
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1627
  "node_modules/internmap": {
1628
  "version": "2.0.3",
1629
  "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -1633,6 +1774,12 @@
1633
  "node": ">=12"
1634
  }
1635
  },
 
 
 
 
 
 
1636
  "node_modules/js-tokens": {
1637
  "version": "4.0.0",
1638
  "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1665,6 +1812,23 @@
1665
  "node": ">=6"
1666
  }
1667
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1668
  "node_modules/lodash": {
1669
  "version": "4.18.1",
1670
  "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1693,6 +1857,30 @@
1693
  "yallist": "^3.0.2"
1694
  }
1695
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1696
  "node_modules/ms": {
1697
  "version": "2.1.3",
1698
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1735,6 +1923,19 @@
1735
  "node": ">=0.10.0"
1736
  }
1737
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1738
  "node_modules/picocolors": {
1739
  "version": "1.1.1",
1740
  "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1788,6 +1989,16 @@
1788
  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
1789
  "license": "MIT"
1790
  },
 
 
 
 
 
 
 
 
 
 
1791
  "node_modules/react": {
1792
  "version": "18.3.1",
1793
  "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1924,6 +2135,23 @@
1924
  "decimal.js-light": "^2.4.1"
1925
  }
1926
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1927
  "node_modules/rollup": {
1928
  "version": "4.60.2",
1929
  "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
@@ -1998,12 +2226,47 @@
1998
  "node": ">=0.10.0"
1999
  }
2000
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2001
  "node_modules/tiny-invariant": {
2002
  "version": "1.3.3",
2003
  "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
2004
  "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
2005
  "license": "MIT"
2006
  },
 
 
 
 
 
 
2007
  "node_modules/typescript": {
2008
  "version": "5.9.3",
2009
  "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2049,6 +2312,15 @@
2049
  "browserslist": ">= 4.21.0"
2050
  }
2051
  },
 
 
 
 
 
 
 
 
 
2052
  "node_modules/victory-vendor": {
2053
  "version": "36.9.2",
2054
  "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
 
8
  "name": "fairvalue-webapp",
9
  "version": "1.0.0",
10
  "dependencies": {
11
+ "framer-motion": "^12.38.0",
12
+ "html2canvas": "^1.4.1",
13
+ "jspdf": "^4.2.1",
14
+ "lucide-react": "^1.8.0",
15
  "react": "^18.3.1",
16
  "react-dom": "^18.3.1",
17
  "react-router-dom": "^6.23.1",
 
1238
  "dev": true,
1239
  "license": "MIT"
1240
  },
1241
+ "node_modules/@types/pako": {
1242
+ "version": "2.0.4",
1243
+ "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
1244
+ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
1245
+ "license": "MIT"
1246
+ },
1247
  "node_modules/@types/prop-types": {
1248
  "version": "15.7.15",
1249
  "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
 
1251
  "dev": true,
1252
  "license": "MIT"
1253
  },
1254
+ "node_modules/@types/raf": {
1255
+ "version": "3.4.3",
1256
+ "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
1257
+ "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
1258
+ "license": "MIT",
1259
+ "optional": true
1260
+ },
1261
  "node_modules/@types/react": {
1262
  "version": "18.3.28",
1263
  "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
 
1279
  "@types/react": "^18.0.0"
1280
  }
1281
  },
1282
+ "node_modules/@types/trusted-types": {
1283
+ "version": "2.0.7",
1284
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
1285
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
1286
+ "license": "MIT",
1287
+ "optional": true
1288
+ },
1289
  "node_modules/@vitejs/plugin-react": {
1290
  "version": "4.7.0",
1291
  "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
 
1307
  "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1308
  }
1309
  },
1310
+ "node_modules/base64-arraybuffer": {
1311
+ "version": "1.0.2",
1312
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
1313
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
1314
+ "license": "MIT",
1315
+ "engines": {
1316
+ "node": ">= 0.6.0"
1317
+ }
1318
+ },
1319
  "node_modules/baseline-browser-mapping": {
1320
  "version": "2.10.21",
1321
  "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
 
1384
  ],
1385
  "license": "CC-BY-4.0"
1386
  },
1387
+ "node_modules/canvg": {
1388
+ "version": "3.0.11",
1389
+ "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
1390
+ "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
1391
+ "license": "MIT",
1392
+ "optional": true,
1393
+ "dependencies": {
1394
+ "@babel/runtime": "^7.12.5",
1395
+ "@types/raf": "^3.4.0",
1396
+ "core-js": "^3.8.3",
1397
+ "raf": "^3.4.1",
1398
+ "regenerator-runtime": "^0.13.7",
1399
+ "rgbcolor": "^1.0.1",
1400
+ "stackblur-canvas": "^2.0.0",
1401
+ "svg-pathdata": "^6.0.3"
1402
+ },
1403
+ "engines": {
1404
+ "node": ">=10.0.0"
1405
+ }
1406
+ },
1407
  "node_modules/clsx": {
1408
  "version": "2.1.1",
1409
  "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
 
1420
  "dev": true,
1421
  "license": "MIT"
1422
  },
1423
+ "node_modules/core-js": {
1424
+ "version": "3.49.0",
1425
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
1426
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
1427
+ "hasInstallScript": true,
1428
+ "license": "MIT",
1429
+ "optional": true,
1430
+ "funding": {
1431
+ "type": "opencollective",
1432
+ "url": "https://opencollective.com/core-js"
1433
+ }
1434
+ },
1435
+ "node_modules/css-line-break": {
1436
+ "version": "2.1.0",
1437
+ "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
1438
+ "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
1439
+ "license": "MIT",
1440
+ "dependencies": {
1441
+ "utrie": "^1.0.2"
1442
+ }
1443
+ },
1444
  "node_modules/csstype": {
1445
  "version": "3.2.3",
1446
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
 
1602
  "csstype": "^3.0.2"
1603
  }
1604
  },
1605
+ "node_modules/dompurify": {
1606
+ "version": "3.4.2",
1607
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
1608
+ "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
1609
+ "license": "(MPL-2.0 OR Apache-2.0)",
1610
+ "optional": true,
1611
+ "optionalDependencies": {
1612
+ "@types/trusted-types": "^2.0.7"
1613
+ }
1614
+ },
1615
  "node_modules/electron-to-chromium": {
1616
  "version": "1.5.343",
1617
  "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz",
 
1683
  "node": ">=6.0.0"
1684
  }
1685
  },
1686
+ "node_modules/fast-png": {
1687
+ "version": "6.4.0",
1688
+ "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
1689
+ "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
1690
+ "license": "MIT",
1691
+ "dependencies": {
1692
+ "@types/pako": "^2.0.3",
1693
+ "iobuffer": "^5.3.2",
1694
+ "pako": "^2.1.0"
1695
+ }
1696
+ },
1697
+ "node_modules/fflate": {
1698
+ "version": "0.8.2",
1699
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
1700
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
1701
+ "license": "MIT"
1702
+ },
1703
+ "node_modules/framer-motion": {
1704
+ "version": "12.38.0",
1705
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
1706
+ "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
1707
+ "license": "MIT",
1708
+ "dependencies": {
1709
+ "motion-dom": "^12.38.0",
1710
+ "motion-utils": "^12.36.0",
1711
+ "tslib": "^2.4.0"
1712
+ },
1713
+ "peerDependencies": {
1714
+ "@emotion/is-prop-valid": "*",
1715
+ "react": "^18.0.0 || ^19.0.0",
1716
+ "react-dom": "^18.0.0 || ^19.0.0"
1717
+ },
1718
+ "peerDependenciesMeta": {
1719
+ "@emotion/is-prop-valid": {
1720
+ "optional": true
1721
+ },
1722
+ "react": {
1723
+ "optional": true
1724
+ },
1725
+ "react-dom": {
1726
+ "optional": true
1727
+ }
1728
+ }
1729
+ },
1730
  "node_modules/fsevents": {
1731
  "version": "2.3.3",
1732
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
 
1752
  "node": ">=6.9.0"
1753
  }
1754
  },
1755
+ "node_modules/html2canvas": {
1756
+ "version": "1.4.1",
1757
+ "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
1758
+ "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
1759
+ "license": "MIT",
1760
+ "dependencies": {
1761
+ "css-line-break": "^2.1.0",
1762
+ "text-segmentation": "^1.0.3"
1763
+ },
1764
+ "engines": {
1765
+ "node": ">=8.0.0"
1766
+ }
1767
+ },
1768
  "node_modules/internmap": {
1769
  "version": "2.0.3",
1770
  "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
 
1774
  "node": ">=12"
1775
  }
1776
  },
1777
+ "node_modules/iobuffer": {
1778
+ "version": "5.4.0",
1779
+ "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
1780
+ "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
1781
+ "license": "MIT"
1782
+ },
1783
  "node_modules/js-tokens": {
1784
  "version": "4.0.0",
1785
  "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
 
1812
  "node": ">=6"
1813
  }
1814
  },
1815
+ "node_modules/jspdf": {
1816
+ "version": "4.2.1",
1817
+ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
1818
+ "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
1819
+ "license": "MIT",
1820
+ "dependencies": {
1821
+ "@babel/runtime": "^7.28.6",
1822
+ "fast-png": "^6.2.0",
1823
+ "fflate": "^0.8.1"
1824
+ },
1825
+ "optionalDependencies": {
1826
+ "canvg": "^3.0.11",
1827
+ "core-js": "^3.6.0",
1828
+ "dompurify": "^3.3.1",
1829
+ "html2canvas": "^1.0.0-rc.5"
1830
+ }
1831
+ },
1832
  "node_modules/lodash": {
1833
  "version": "4.18.1",
1834
  "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
 
1857
  "yallist": "^3.0.2"
1858
  }
1859
  },
1860
+ "node_modules/lucide-react": {
1861
+ "version": "1.8.0",
1862
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
1863
+ "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
1864
+ "license": "ISC",
1865
+ "peerDependencies": {
1866
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
1867
+ }
1868
+ },
1869
+ "node_modules/motion-dom": {
1870
+ "version": "12.38.0",
1871
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
1872
+ "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
1873
+ "license": "MIT",
1874
+ "dependencies": {
1875
+ "motion-utils": "^12.36.0"
1876
+ }
1877
+ },
1878
+ "node_modules/motion-utils": {
1879
+ "version": "12.36.0",
1880
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
1881
+ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
1882
+ "license": "MIT"
1883
+ },
1884
  "node_modules/ms": {
1885
  "version": "2.1.3",
1886
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
 
1923
  "node": ">=0.10.0"
1924
  }
1925
  },
1926
+ "node_modules/pako": {
1927
+ "version": "2.1.0",
1928
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
1929
+ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
1930
+ "license": "(MIT AND Zlib)"
1931
+ },
1932
+ "node_modules/performance-now": {
1933
+ "version": "2.1.0",
1934
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
1935
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
1936
+ "license": "MIT",
1937
+ "optional": true
1938
+ },
1939
  "node_modules/picocolors": {
1940
  "version": "1.1.1",
1941
  "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
 
1989
  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
1990
  "license": "MIT"
1991
  },
1992
+ "node_modules/raf": {
1993
+ "version": "3.4.1",
1994
+ "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
1995
+ "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
1996
+ "license": "MIT",
1997
+ "optional": true,
1998
+ "dependencies": {
1999
+ "performance-now": "^2.1.0"
2000
+ }
2001
+ },
2002
  "node_modules/react": {
2003
  "version": "18.3.1",
2004
  "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
 
2135
  "decimal.js-light": "^2.4.1"
2136
  }
2137
  },
2138
+ "node_modules/regenerator-runtime": {
2139
+ "version": "0.13.11",
2140
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
2141
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
2142
+ "license": "MIT",
2143
+ "optional": true
2144
+ },
2145
+ "node_modules/rgbcolor": {
2146
+ "version": "1.0.1",
2147
+ "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
2148
+ "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
2149
+ "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
2150
+ "optional": true,
2151
+ "engines": {
2152
+ "node": ">= 0.8.15"
2153
+ }
2154
+ },
2155
  "node_modules/rollup": {
2156
  "version": "4.60.2",
2157
  "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
 
2226
  "node": ">=0.10.0"
2227
  }
2228
  },
2229
+ "node_modules/stackblur-canvas": {
2230
+ "version": "2.7.0",
2231
+ "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
2232
+ "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
2233
+ "license": "MIT",
2234
+ "optional": true,
2235
+ "engines": {
2236
+ "node": ">=0.1.14"
2237
+ }
2238
+ },
2239
+ "node_modules/svg-pathdata": {
2240
+ "version": "6.0.3",
2241
+ "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
2242
+ "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
2243
+ "license": "MIT",
2244
+ "optional": true,
2245
+ "engines": {
2246
+ "node": ">=12.0.0"
2247
+ }
2248
+ },
2249
+ "node_modules/text-segmentation": {
2250
+ "version": "1.0.3",
2251
+ "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
2252
+ "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
2253
+ "license": "MIT",
2254
+ "dependencies": {
2255
+ "utrie": "^1.0.2"
2256
+ }
2257
+ },
2258
  "node_modules/tiny-invariant": {
2259
  "version": "1.3.3",
2260
  "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
2261
  "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
2262
  "license": "MIT"
2263
  },
2264
+ "node_modules/tslib": {
2265
+ "version": "2.8.1",
2266
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2267
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2268
+ "license": "0BSD"
2269
+ },
2270
  "node_modules/typescript": {
2271
  "version": "5.9.3",
2272
  "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
 
2312
  "browserslist": ">= 4.21.0"
2313
  }
2314
  },
2315
+ "node_modules/utrie": {
2316
+ "version": "1.0.2",
2317
+ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
2318
+ "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
2319
+ "license": "MIT",
2320
+ "dependencies": {
2321
+ "base64-arraybuffer": "^1.0.2"
2322
+ }
2323
+ },
2324
  "node_modules/victory-vendor": {
2325
  "version": "36.9.2",
2326
  "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
fairvalue-webapp/package.json CHANGED
@@ -10,6 +10,10 @@
10
  "preview": "vite preview"
11
  },
12
  "dependencies": {
 
 
 
 
13
  "react": "^18.3.1",
14
  "react-dom": "^18.3.1",
15
  "react-router-dom": "^6.23.1",
 
10
  "preview": "vite preview"
11
  },
12
  "dependencies": {
13
+ "framer-motion": "^12.38.0",
14
+ "html2canvas": "^1.4.1",
15
+ "jspdf": "^4.2.1",
16
+ "lucide-react": "^1.8.0",
17
  "react": "^18.3.1",
18
  "react-dom": "^18.3.1",
19
  "react-router-dom": "^6.23.1",
fairvalue-webapp/src/App.tsx CHANGED
@@ -4,16 +4,19 @@ import Landing from './pages/Landing'
4
  import Estimator from './pages/Estimator'
5
  import FFPAdvisor from './pages/FFPAdvisor'
6
  import Intel from './pages/Intel'
 
 
7
 
8
  export default function App() {
9
  return (
10
  <>
11
  <Navbar />
12
  <Routes>
13
- <Route path="/" element={<Landing />} />
14
  <Route path="/estimate" element={<Estimator />} />
15
- <Route path="/ffp" element={<FFPAdvisor />} />
16
  <Route path="/intel" element={<Intel />} />
 
17
  </Routes>
18
  </>
19
  )
 
4
  import Estimator from './pages/Estimator'
5
  import FFPAdvisor from './pages/FFPAdvisor'
6
  import Intel from './pages/Intel'
7
+ import AboutDeveloper from './pages/AboutDeveloper'
8
+ import SecretGate, { useAccessControl } from './components/SecretGate'
9
 
10
  export default function App() {
11
  return (
12
  <>
13
  <Navbar />
14
  <Routes>
15
+ <Route path="/" element={<Landing />} />
16
  <Route path="/estimate" element={<Estimator />} />
17
+ <Route path="/ffp" element={<FFPAdvisor/>} />
18
  <Route path="/intel" element={<Intel />} />
19
+ <Route path="/about" element={<AboutDeveloper />} />
20
  </Routes>
21
  </>
22
  )
fairvalue-webapp/src/components/AccessModal.tsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { Lock, KeyRound } from 'lucide-react';
4
+
5
+ interface AccessModalProps {
6
+ onBetaRequest?: () => void;
7
+ }
8
+
9
+ export default function AccessModal({ onBetaRequest }: AccessModalProps) {
10
+ const [showCodeInput, setShowCodeInput] = useState(false);
11
+ const [code, setCode] = useState('');
12
+ const [error, setError] = useState(false);
13
+ const [loading, setLoading] = useState(false);
14
+ const [errorMessage, setErrorMessage] = useState('');
15
+
16
+ const handleUnlock = async (e: React.FormEvent) => {
17
+ e.preventDefault();
18
+ setLoading(true);
19
+ setError(false);
20
+ setErrorMessage('');
21
+
22
+ try {
23
+ const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
24
+ const response = await fetch(`${apiUrl}/api/validate-code`, {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body: JSON.stringify({ code }),
28
+ });
29
+
30
+ const data = await response.json();
31
+
32
+ if (response.ok && data.status === 'success') {
33
+ sessionStorage.setItem('fv_access_granted', 'true');
34
+ window.location.reload();
35
+ } else {
36
+ setError(true);
37
+ setErrorMessage(data.detail || 'Invalid or expired code.');
38
+ }
39
+ } catch (err) {
40
+ setError(true);
41
+ setErrorMessage('Server connection failed. Try again later.');
42
+ } finally {
43
+ setLoading(false);
44
+ }
45
+ };
46
+
47
+ return (
48
+ <div className="modal-overlay">
49
+ <motion.div
50
+ initial={{ opacity: 0, y: 30, scale: 0.95 }}
51
+ animate={{ opacity: 1, y: 0, scale: 1 }}
52
+ transition={{ duration: 0.4, ease: "easeOut" }}
53
+ className="modal-content"
54
+ >
55
+ <div style={{ textAlign: 'center', marginBottom: '28px' }}>
56
+ <div style={{
57
+ width: 64, height: 64, borderRadius: '50%',
58
+ background: 'rgba(59, 130, 246, 0.1)',
59
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
60
+ margin: '0 auto 20px',
61
+ border: '1px solid rgba(59, 130, 246, 0.2)'
62
+ }}>
63
+ <Lock size={28} color="#3b82f6" />
64
+ </div>
65
+ <h2 style={{ marginBottom: '12px', fontSize: '1.6rem' }} className="display-font">Premium Access Required</h2>
66
+ <p style={{ fontSize: '0.95rem' }}>
67
+ You have reached the maximum limit of 3 free AI transfer evaluations.
68
+ Request full enterprise access to unlock unlimited SHAP profiling, live intelligence grids, and PSR compliance modeling.
69
+ </p>
70
+ </div>
71
+
72
+ <AnimatePresence mode="wait">
73
+ {!showCodeInput ? (
74
+ <motion.form
75
+ key="request-form"
76
+ initial={{ opacity: 0, x: -20 }}
77
+ animate={{ opacity: 1, x: 0 }}
78
+ exit={{ opacity: 0, x: 20 }}
79
+ action="https://formsubmit.co/oladeji.lawrence@gmail.com"
80
+ method="POST"
81
+ style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
82
+ >
83
+ <input type="text" name="_honey" style={{ display: 'none' }} />
84
+ <input type="hidden" name="_subject" value="FairValue Enterprise Access Request!" />
85
+ <input type="hidden" name="_captcha" value="false" />
86
+
87
+ <div className="input-group">
88
+ <label className="field-label">Full Name</label>
89
+ <input type="text" name="name" className="input" placeholder="e.g. Edu Gaspar" required />
90
+ </div>
91
+
92
+ <div className="input-group">
93
+ <label className="field-label">Work Email</label>
94
+ <input type="email" name="email" className="input" placeholder="director@club.com" required />
95
+ </div>
96
+
97
+ <div className="input-group">
98
+ <label className="field-label">Organisation / Club</label>
99
+ <input type="text" name="organisation" className="input" placeholder="Arsenal FC" required />
100
+ </div>
101
+
102
+ <button type="submit" className="btn btn-secondary" style={{ marginTop: '12px', width: '100%' }}>
103
+ Request Enterprise Access
104
+ </button>
105
+
106
+ <button type="button" onClick={() => setShowCodeInput(true)} className="btn btn-ghost" style={{ width: '100%', fontSize: '0.85rem', padding: '10px' }}>
107
+ <KeyRound size={14} style={{ marginRight: 6 }}/> Already have an access code?
108
+ </button>
109
+ </motion.form>
110
+ ) : (
111
+ <motion.form
112
+ key="code-form"
113
+ initial={{ opacity: 0, x: 20 }}
114
+ animate={{ opacity: 1, x: 0 }}
115
+ exit={{ opacity: 0, x: -20 }}
116
+ onSubmit={handleUnlock}
117
+ style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
118
+ >
119
+ <div className="input-group" style={{ marginBottom: '8px' }}>
120
+ <label className="field-label">Enter Secret Code</label>
121
+ <input
122
+ type="password"
123
+ className="input"
124
+ placeholder="e.g. FV-ALPHA-101"
125
+ value={code}
126
+ onChange={(e) => { setCode(e.target.value); setError(false); }}
127
+ style={{ borderColor: error ? 'var(--loss-color)' : '' }}
128
+ autoFocus
129
+ disabled={loading}
130
+ />
131
+ {error && <span style={{ color: 'var(--loss-color)', fontSize: '0.8rem', marginTop: '4px' }}>{errorMessage}</span>}
132
+ </div>
133
+
134
+ <button type="submit" className="btn btn-secondary" style={{ width: '100%' }} disabled={loading}>
135
+ {loading ? 'Validating Access...' : 'Unlock Platform'}
136
+ </button>
137
+
138
+ <button type="button" onClick={() => setShowCodeInput(false)} className="btn btn-ghost" style={{ width: '100%', fontSize: '0.85rem', padding: '10px' }}>
139
+ ← Back to Request Form
140
+ </button>
141
+ </motion.form>
142
+ )}
143
+ </AnimatePresence>
144
+ </motion.div>
145
+ </div>
146
+ );
147
+ }
fairvalue-webapp/src/components/DefinitionOfTerms.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function DefinitionOfTerms() {
2
+ return (
3
+ <div className="glass" style={{ padding: '32px', marginTop: '32px' }}>
4
+ <h3 style={{ marginBottom: '20px', color: 'var(--text-1)' }}>Definition of Terms</h3>
5
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '24px' }}>
6
+
7
+ <div>
8
+ <h4 style={{ color: 'var(--profit-color)', marginBottom: '6px' }}>Intrinsic Performance Value</h4>
9
+ <p style={{ fontSize: '0.85rem', color: 'var(--text-2)' }}>
10
+ The player's raw talent value, calculated purely from on-pitch statistics (goals, assists, defensive actions, pass completion) independent of external factors.
11
+ </p>
12
+ </div>
13
+
14
+ <div>
15
+ <h4 style={{ color: 'var(--accent-blue)', marginBottom: '6px' }}>Age & Contract Impact (SHAP)</h4>
16
+ <p style={{ fontSize: '0.85rem', color: 'var(--text-2)' }}>
17
+ The financial adjustment applied to the player's value based on their age and remaining contract years, isolating exact premiums or penalties using Explainable AI (SHAP).
18
+ </p>
19
+ </div>
20
+
21
+ <div>
22
+ <h4 style={{ color: '#a78bfa', marginBottom: '6px' }}>ML Baseline Value</h4>
23
+ <p style={{ fontSize: '0.85rem', color: 'var(--text-2)' }}>
24
+ The objective base market valuation before considering live news, transfer speculation, and recent injury data.
25
+ </p>
26
+ </div>
27
+
28
+ <div>
29
+ <h4 style={{ color: '#fbbf24', marginBottom: '6px' }}>NLP Multiplier</h4>
30
+ <p style={{ fontSize: '0.85rem', color: 'var(--text-2)' }}>
31
+ A dynamic market adjustment factor generated by scanning live news sentiment, agent rumors, and recent injury reports.
32
+ </p>
33
+ </div>
34
+
35
+ <div>
36
+ <h4 style={{ color: 'var(--profit-color)', marginBottom: '6px' }}>Fair Value</h4>
37
+ <p style={{ fontSize: '0.85rem', color: 'var(--text-2)' }}>
38
+ The absolute maximum recommended price (financial ceiling). If the asking price exceeds this Fair Value, it represents a significant overpayment risk based on real-time market and predictive metrics.
39
+ </p>
40
+ </div>
41
+
42
+ </div>
43
+ </div>
44
+ )
45
+ }
fairvalue-webapp/src/components/Navbar.tsx CHANGED
@@ -4,6 +4,7 @@ const links = [
4
  { to: '/estimate', label: 'Transfer Estimator' },
5
  { to: '/ffp', label: 'PSR Advisor' },
6
  { to: '/intel', label: 'Live Intel' },
 
7
  ]
8
 
9
  export default function Navbar() {
@@ -42,8 +43,8 @@ export default function Navbar() {
42
  borderRadius: 8,
43
  fontSize: '0.85rem',
44
  fontWeight: isActive ? 600 : 400,
45
- color: isActive ? 'var(--green)' : 'var(--text-2)',
46
- background: isActive ? 'var(--green-dim)' : 'transparent',
47
  transition: 'all 0.2s',
48
  textDecoration: 'none',
49
  })}
@@ -53,7 +54,7 @@ export default function Navbar() {
53
 
54
  {/* Badge */}
55
  <span className="badge badge-blue" style={{ fontSize:'0.68rem', display:'flex', alignItems:'center', gap:5 }}>
56
- <span style={{ width:6, height:6, borderRadius:'50%', background:'var(--green)', display:'inline-block', animation:'pulse-ring 2s infinite' }}/>
57
  XGBoost + SHAP
58
  </span>
59
  </div>
 
4
  { to: '/estimate', label: 'Transfer Estimator' },
5
  { to: '/ffp', label: 'PSR Advisor' },
6
  { to: '/intel', label: 'Live Intel' },
7
+ { to: '/about', label: 'About the Architect' },
8
  ]
9
 
10
  export default function Navbar() {
 
43
  borderRadius: 8,
44
  fontSize: '0.85rem',
45
  fontWeight: isActive ? 600 : 400,
46
+ color: isActive ? 'var(--profit-color)' : 'var(--text-2)',
47
+ background: isActive ? 'rgba(34,197,94,0.15)' : 'transparent',
48
  transition: 'all 0.2s',
49
  textDecoration: 'none',
50
  })}
 
54
 
55
  {/* Badge */}
56
  <span className="badge badge-blue" style={{ fontSize:'0.68rem', display:'flex', alignItems:'center', gap:5 }}>
57
+ <span style={{ width:6, height:6, borderRadius:'50%', background:'var(--profit-color)', display:'inline-block', animation:'pulse-ring 2s infinite' }}/>
58
  XGBoost + SHAP
59
  </span>
60
  </div>
fairvalue-webapp/src/components/ReportTemplate.tsx ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { forwardRef } from 'react'
2
+
3
+ export interface ReportProps {
4
+ form: {
5
+ selected_name: string
6
+ current_club: string
7
+ interested_club: string
8
+ contract_years: number
9
+ age: number
10
+ injuries_24m: number
11
+ asking_price: number
12
+ market_value_estimation: number
13
+ }
14
+ result: {
15
+ ledger: {
16
+ intrinsic_performance_value: number
17
+ category: string
18
+ depreciation: number
19
+ baseline_value: number
20
+ external_multiplier: number
21
+ hard_cap: number
22
+ }
23
+ nlp_results?: {
24
+ durability: number
25
+ recency: number
26
+ agent: number
27
+ }
28
+ }
29
+ }
30
+
31
+ export const ReportTemplate = forwardRef<HTMLDivElement, ReportProps>(({ form, result }, ref) => {
32
+ const L = result.ledger
33
+ const isOverpay = form.asking_price > L.hard_cap
34
+ const dateStr = new Date().toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short' })
35
+
36
+ return (
37
+ <div
38
+ ref={ref}
39
+ className="printable-report"
40
+ style={{
41
+ backgroundColor: '#ffffff',
42
+ color: '#0f172a',
43
+ fontFamily: "'Inter', sans-serif",
44
+ boxSizing: 'border-box',
45
+ display: 'none', // Hidden on screen, shown in print via CSS
46
+ }}
47
+ >
48
+ {/* HEADER */}
49
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', borderBottom: '2px solid #e2e8f0', paddingBottom: '24px', marginBottom: '32px' }}>
50
+ <div>
51
+ <h1 style={{ fontSize: '32px', fontWeight: 800, margin: 0, color: '#020617', letterSpacing: '-0.03em' }}>
52
+ FairValue Strategic Report
53
+ </h1>
54
+ <p style={{ fontSize: '14px', color: '#64748b', marginTop: '4px' }}>
55
+ AI-Driven Transfer Valuation Intelligence
56
+ </p>
57
+ </div>
58
+ <div style={{ textAlign: 'right', fontSize: '12px', color: '#475569', lineHeight: 1.6 }}>
59
+ <div style={{ fontWeight: 600, color: '#0f172a' }}>Lawrence Oladeji</div>
60
+ <div>oladeji.lawrence@gmail.com</div>
61
+ <div><a href="https://wa.me/2349038819790" style={{ color: '#0f172a', textDecoration: 'none' }}>WhatsApp</a></div>
62
+ <div><a href="https://premiership-player-fair-value.vercel.app/" style={{ color: '#2563eb', textDecoration: 'none' }}>Website</a></div>
63
+ <div style={{ marginTop: '8px', color: '#94a3b8' }}>Generated: {dateStr}</div>
64
+ </div>
65
+ </div>
66
+
67
+ {/* PLAYER PROFILE */}
68
+ <div style={{ marginBottom: '32px', pageBreakInside: 'avoid', breakInside: 'avoid' }}>
69
+ <h2 style={{ fontSize: '18px', fontWeight: 700, color: '#334155', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '16px' }}>
70
+ Player Profile & Parameters
71
+ </h2>
72
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', background: '#f8fafc', padding: '24px', borderRadius: '12px', border: '1px solid #e2e8f0' }}>
73
+ <div>
74
+ <div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Target Player</div>
75
+ <div style={{ fontSize: '20px', fontWeight: 700, color: '#0f172a' }}>{form.selected_name || 'N/A'}</div>
76
+ </div>
77
+ <div>
78
+ <div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Transfer Direction</div>
79
+ <div style={{ fontSize: '16px', fontWeight: 600, color: '#0f172a' }}>
80
+ {form.current_club || 'Unknown'} → {form.interested_club || 'Unknown'}
81
+ </div>
82
+ </div>
83
+ <div>
84
+ <div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Age</div>
85
+ <div style={{ fontSize: '16px', fontWeight: 600, color: '#0f172a' }}>{form.age} years</div>
86
+ </div>
87
+ <div>
88
+ <div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Contract Remaining</div>
89
+ <div style={{ fontSize: '16px', fontWeight: 600, color: '#0f172a' }}>{form.contract_years} years</div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ {/* FINANCIAL VALUATION LEDGER */}
95
+ <div style={{ marginBottom: '32px', pageBreakInside: 'avoid', breakInside: 'avoid' }}>
96
+ <h2 style={{ fontSize: '18px', fontWeight: 700, color: '#334155', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '16px' }}>
97
+ AI Valuation Ledger
98
+ </h2>
99
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
100
+ <tbody>
101
+ <tr style={{ borderBottom: '1px solid #e2e8f0' }}>
102
+ <td style={{ padding: '12px 0', color: '#475569', fontWeight: 500 }}>Intrinsic Performance Value</td>
103
+ <td style={{ padding: '12px 0', textAlign: 'right', fontWeight: 700, color: '#0f172a' }}>£{L.intrinsic_performance_value.toFixed(1)}m</td>
104
+ </tr>
105
+ <tr style={{ borderBottom: '1px solid #e2e8f0' }}>
106
+ <td style={{ padding: '12px 0', color: '#475569', fontWeight: 500 }}>
107
+ Age & Contract Impact (SHAP) — {L.depreciation < 0 ? 'Penalty' : 'Premium'}
108
+ </td>
109
+ <td style={{ padding: '12px 0', textAlign: 'right', fontWeight: 700, color: L.depreciation < 0 ? '#ef4444' : '#22c55e' }}>
110
+ {L.depreciation >= 0 ? '+' : ''}£{L.depreciation.toFixed(1)}m
111
+ </td>
112
+ </tr>
113
+ <tr style={{ borderBottom: '1px solid #e2e8f0' }}>
114
+ <td style={{ padding: '12px 0', color: '#475569', fontWeight: 500 }}>ML Baseline Value</td>
115
+ <td style={{ padding: '12px 0', textAlign: 'right', fontWeight: 700, color: '#0f172a' }}>£{L.baseline_value.toFixed(1)}m</td>
116
+ </tr>
117
+ <tr style={{ borderBottom: '1px solid #e2e8f0' }}>
118
+ <td style={{ padding: '12px 0', color: '#475569', fontWeight: 500 }}>Live NLP Market Multiplier</td>
119
+ <td style={{ padding: '12px 0', textAlign: 'right', fontWeight: 700, color: L.external_multiplier > 1 ? '#22c55e' : '#ef4444' }}>
120
+ ×{L.external_multiplier.toFixed(3)}
121
+ </td>
122
+ </tr>
123
+ <tr style={{ backgroundColor: '#f0fdf4', border: '1px solid #bbf7d0' }}>
124
+ <td style={{ padding: '16px 12px', color: '#166534', fontWeight: 700, fontSize: '16px' }}>Calculated Fair Value</td>
125
+ <td style={{ padding: '16px 12px', textAlign: 'right', fontWeight: 800, fontSize: '20px', color: '#16a34a' }}>
126
+ £{L.hard_cap.toFixed(1)}m
127
+ </td>
128
+ </tr>
129
+ </tbody>
130
+ </table>
131
+ </div>
132
+
133
+ {/* NLP INTELLIGENCE BREAKDOWN */}
134
+ {result.nlp_results && (
135
+ <div style={{ marginBottom: '32px', pageBreakInside: 'avoid', breakInside: 'avoid' }}>
136
+ <h2 style={{ fontSize: '18px', fontWeight: 700, color: '#334155', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '16px' }}>
137
+ Live Market Intelligence (NLP Breakdown)
138
+ </h2>
139
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px', background: '#f8fafc', padding: '24px', borderRadius: '12px', border: '1px solid #e2e8f0' }}>
140
+ <div>
141
+ <div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Recent Form & Impact</div>
142
+ <div style={{ fontSize: '18px', fontWeight: 700, color: result.nlp_results.recency < 0 ? '#ef4444' : '#22c55e' }}>
143
+ {result.nlp_results.recency > 0 ? '+' : ''}{result.nlp_results.recency.toFixed(2)}
144
+ </div>
145
+ </div>
146
+ <div>
147
+ <div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Injury / Availability</div>
148
+ <div style={{ fontSize: '18px', fontWeight: 700, color: result.nlp_results.durability < 0 ? '#ef4444' : '#0f172a' }}>
149
+ {result.nlp_results.durability > 0 ? '+' : ''}{result.nlp_results.durability.toFixed(2)}
150
+ </div>
151
+ </div>
152
+ <div>
153
+ <div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Transfer Speculation</div>
154
+ <div style={{ fontSize: '18px', fontWeight: 700, color: result.nlp_results.agent < 0 ? '#ef4444' : '#22c55e' }}>
155
+ {result.nlp_results.agent > 0 ? '+' : ''}{result.nlp_results.agent.toFixed(2)}
156
+ </div>
157
+ </div>
158
+ </div>
159
+ <div style={{ fontSize: '13px', color: '#64748b', marginTop: '12px', lineHeight: 1.5 }}>
160
+ <strong>How this works:</strong> The Live NLP Market Multiplier (currently <strong>×{L.external_multiplier.toFixed(3)}</strong>) is calculated from the sentiment scores above. A multiplier of exactly 1.000 means perfectly neutral market hype. A score above 1.0 indicates high demand and positive news, allowing the selling club to charge a premium. A score below 1.0 indicates negative press (e.g. poor form or injury history), creating leverage to negotiate a discount.
161
+ </div>
162
+ </div>
163
+ )}
164
+
165
+ {/* VERDICT & SUMMARY */}
166
+ <div style={{ marginBottom: '40px', pageBreakBefore: 'always', breakBefore: 'page' }}>
167
+ <h2 style={{ fontSize: '18px', fontWeight: 700, color: '#334155', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '16px' }}>
168
+ Executive Summary & Verdict
169
+ </h2>
170
+ <div style={{ padding: '24px', background: isOverpay ? '#fef2f2' : '#f0fdf4', border: `1px solid ${isOverpay ? '#fecaca' : '#bbf7d0'}`, borderRadius: '12px' }}>
171
+ <p style={{ fontSize: '15px', color: isOverpay ? '#991b1b' : '#166534', lineHeight: 1.6, marginBottom: '16px' }}>
172
+ <strong>Negotiation Intel:</strong> Based on the real-time evaluation engine, {form.selected_name}'s intrinsic value sits at £{L.intrinsic_performance_value.toFixed(1)}m.
173
+ Adjusting for age and contract dynamics (£{L.depreciation.toFixed(1)}m) and live market NLP sentiment (×{L.external_multiplier.toFixed(3)}),
174
+ the absolute financial ceiling (Fair Value) is strictly calculated at <strong>£{L.hard_cap.toFixed(1)}m</strong>.
175
+ <br/><br/>
176
+ <strong>Validation:</strong> {result.nlp_results.recency > 0 ? "Player's recent form commands a premium in the market." : "Recent form is suboptimal, creating leverage for a lower fee."} {L.depreciation < 0 ? "Age profile indicates significant depreciation risk—use this to negotiate down." : "Player's age and contract profile guarantees long-term asset retention, justifying a premium."}
177
+ </p>
178
+ <div style={{ fontSize: '18px', fontWeight: 800, color: isOverpay ? '#dc2626' : '#16a34a', borderTop: `1px solid ${isOverpay ? '#fca5a5' : '#86efac'}`, paddingTop: '16px' }}>
179
+ VERDICT: {isOverpay ? 'OVERPAY RISK' : 'FAIR DEAL - PROCEED WITH CONFIDENCE'}
180
+ </div>
181
+ <div style={{ fontSize: '14px', color: isOverpay ? '#b91c1c' : '#15803d', marginTop: '6px' }}>
182
+ The selling club's asking price of £{form.asking_price}m {isOverpay ? 'exceeds' : 'is safely within'} our calculated Fair Value.
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+ {/* DEFINITION OF TERMS */}
188
+ <div style={{ borderTop: '2px solid #e2e8f0', paddingTop: '24px', pageBreakInside: 'avoid', breakInside: 'avoid' }}>
189
+ <h2 style={{ fontSize: '14px', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '12px' }}>
190
+ Definition of Terms
191
+ </h2>
192
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', fontSize: '12px', color: '#475569', lineHeight: 1.5 }}>
193
+ <div>
194
+ <strong style={{ color: '#0f172a' }}>Intrinsic Performance Value:</strong> Player's raw talent value derived purely from on-pitch statistics.
195
+ </div>
196
+ <div>
197
+ <strong style={{ color: '#0f172a' }}>Age & Contract Impact:</strong> Financial adjustment based on age and remaining contract years.
198
+ </div>
199
+ <div>
200
+ <strong style={{ color: '#0f172a' }}>NLP Multiplier:</strong> Live web-scraping adjustment factor. Values {'>'} 1.0 indicate positive hype (premium), while values {'<'} 1.0 indicate negative press (discount).
201
+ </div>
202
+ <div>
203
+ <strong style={{ color: '#0f172a' }}>Fair Value:</strong> The absolute maximum recommended price (financial ceiling).
204
+ </div>
205
+ </div>
206
+ </div>
207
+
208
+ </div>
209
+ )
210
+ })
fairvalue-webapp/src/components/SecretGate.tsx ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { ShieldCheck, KeyRound, AlertTriangle } from 'lucide-react';
4
+ const STORAGE_KEY = 'fv_access_granted';
5
+
6
+ export function useAccessControl() {
7
+ const [granted, setGranted] = useState<boolean>(() => {
8
+ return sessionStorage.getItem(STORAGE_KEY) === 'true';
9
+ });
10
+
11
+ const grant = () => {
12
+ sessionStorage.setItem(STORAGE_KEY, 'true');
13
+ setGranted(true);
14
+ };
15
+
16
+ return { granted, grant };
17
+ }
18
+
19
+ interface SecretGateProps {
20
+ onGranted: () => void;
21
+ }
22
+
23
+ export default function SecretGate({ onGranted }: SecretGateProps) {
24
+ const [code, setCode] = useState('');
25
+ const [error, setError] = useState(false);
26
+ const [errorMessage, setErrorMessage] = useState('');
27
+ const [shake, setShake] = useState(false);
28
+ const [loading, setLoading] = useState(false);
29
+ const inputRef = useRef<HTMLInputElement>(null);
30
+
31
+ useEffect(() => {
32
+ inputRef.current?.focus();
33
+ }, []);
34
+
35
+ const handleSubmit = async (e: React.FormEvent) => {
36
+ e.preventDefault();
37
+ setLoading(true);
38
+ setError(false);
39
+ setErrorMessage('');
40
+
41
+ try {
42
+ const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
43
+ const response = await fetch(`${apiUrl}/api/validate-code`, {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body: JSON.stringify({ code }),
47
+ });
48
+
49
+ const data = await response.json();
50
+
51
+ if (response.ok && data.status === 'success') {
52
+ onGranted();
53
+ } else {
54
+ setError(true);
55
+ setShake(true);
56
+ setErrorMessage(data.detail || 'Invalid or expired code.');
57
+ setCode('');
58
+ setTimeout(() => setShake(false), 600);
59
+ }
60
+ } catch (err) {
61
+ setError(true);
62
+ setShake(true);
63
+ setErrorMessage('Server connection failed.');
64
+ setTimeout(() => setShake(false), 600);
65
+ } finally {
66
+ setLoading(false);
67
+ }
68
+ };
69
+
70
+ return (
71
+ <div style={{
72
+ position: 'fixed', inset: 0, zIndex: 9999,
73
+ background: 'radial-gradient(ellipse at 30% 20%, rgba(59,130,246,0.12) 0%, transparent 60%), radial-gradient(ellipse at 70% 80%, rgba(34,197,94,0.08) 0%, transparent 60%), var(--bg-0)',
74
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
75
+ backdropFilter: 'blur(12px)',
76
+ }}>
77
+ {/* Ambient orbs */}
78
+ <div style={{ position: 'absolute', top: '10%', left: '15%', width: 400, height: 400, background: 'rgba(59,130,246,0.06)', borderRadius: '50%', filter: 'blur(120px)', pointerEvents: 'none' }} />
79
+ <div style={{ position: 'absolute', bottom: '15%', right: '10%', width: 350, height: 350, background: 'rgba(34,197,94,0.05)', borderRadius: '50%', filter: 'blur(100px)', pointerEvents: 'none' }} />
80
+
81
+ <motion.div
82
+ initial={{ opacity: 0, y: 40, scale: 0.96 }}
83
+ animate={shake ? { x: [-12, 12, -8, 8, -4, 4, 0] } : { opacity: 1, y: 0, scale: 1 }}
84
+ transition={shake ? { duration: 0.5 } : { duration: 0.5, ease: 'easeOut' }}
85
+ style={{
86
+ width: '100%', maxWidth: '460px', margin: '0 24px',
87
+ background: 'rgba(255,255,255,0.04)',
88
+ border: error ? '1px solid rgba(239,68,68,0.4)' : '1px solid rgba(255,255,255,0.08)',
89
+ borderRadius: '20px',
90
+ padding: '48px 40px',
91
+ backdropFilter: 'blur(24px)',
92
+ boxShadow: error
93
+ ? '0 0 0 1px rgba(239,68,68,0.2), 0 32px 80px rgba(0,0,0,0.5)'
94
+ : '0 32px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04)',
95
+ transition: 'border-color 0.3s, box-shadow 0.3s',
96
+ position: 'relative',
97
+ }}
98
+ >
99
+ {/* Header */}
100
+ <div style={{ textAlign: 'center', marginBottom: '36px' }}>
101
+ <motion.div
102
+ animate={{ rotate: [0, -5, 5, -3, 3, 0] }}
103
+ transition={{ duration: 2, repeat: Infinity, repeatDelay: 4 }}
104
+ style={{
105
+ width: 72, height: 72, borderRadius: '50%',
106
+ background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(34,197,94,0.1))',
107
+ border: '1px solid rgba(59,130,246,0.25)',
108
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
109
+ margin: '0 auto 24px',
110
+ }}
111
+ >
112
+ <KeyRound size={30} color="#60a5fa" />
113
+ </motion.div>
114
+
115
+ <div style={{
116
+ display: 'inline-flex', alignItems: 'center', gap: 6,
117
+ padding: '4px 14px',
118
+ background: 'rgba(59,130,246,0.1)',
119
+ border: '1px solid rgba(59,130,246,0.2)',
120
+ borderRadius: 100, marginBottom: 20,
121
+ }}>
122
+ <ShieldCheck size={12} color="#3b82f6" />
123
+ <span style={{ fontSize: '0.72rem', fontWeight: 700, color: '#60a5fa', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
124
+ Restricted Access
125
+ </span>
126
+ </div>
127
+
128
+ <h2 style={{ fontSize: '1.75rem', fontWeight: 800, marginBottom: 12, letterSpacing: '-0.03em' }}
129
+ className="display-font">
130
+ Enter Access Code
131
+ </h2>
132
+ <p style={{ color: 'var(--text-3)', fontSize: '0.9rem', lineHeight: 1.7 }}>
133
+ This platform is restricted to authorised personnel. Enter your secret access code to continue.
134
+ </p>
135
+ </div>
136
+
137
+ {/* Form */}
138
+ <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
139
+ <div style={{ position: 'relative' }}>
140
+ <input
141
+ ref={inputRef}
142
+ type="password"
143
+ value={code}
144
+ onChange={e => { setCode(e.target.value); setError(false); }}
145
+ placeholder="FV-ALPHA-101"
146
+ autoComplete="off"
147
+ disabled={loading}
148
+ style={{
149
+ width: '100%',
150
+ padding: '14px 18px',
151
+ background: 'rgba(255,255,255,0.05)',
152
+ border: error ? '1px solid rgba(239,68,68,0.6)' : '1px solid rgba(255,255,255,0.1)',
153
+ borderRadius: 12,
154
+ color: 'var(--text-1)',
155
+ fontSize: '1rem',
156
+ letterSpacing: '0.15em',
157
+ outline: 'none',
158
+ transition: 'border-color 0.2s',
159
+ boxSizing: 'border-box',
160
+ }}
161
+ />
162
+ </div>
163
+
164
+ <AnimatePresence>
165
+ {error && (
166
+ <motion.div
167
+ initial={{ opacity: 0, y: -8 }}
168
+ animate={{ opacity: 1, y: 0 }}
169
+ exit={{ opacity: 0 }}
170
+ style={{
171
+ display: 'flex', alignItems: 'center', gap: 8,
172
+ padding: '10px 14px',
173
+ background: 'rgba(239,68,68,0.08)',
174
+ border: '1px solid rgba(239,68,68,0.2)',
175
+ borderRadius: 10,
176
+ color: '#f87171', fontSize: '0.85rem',
177
+ }}
178
+ >
179
+ <AlertTriangle size={15} />
180
+ {errorMessage}
181
+ </motion.div>
182
+ )}
183
+ </AnimatePresence>
184
+
185
+ <button
186
+ type="submit"
187
+ className="btn btn-secondary"
188
+ disabled={loading}
189
+ style={{ width: '100%', padding: '14px', fontSize: '0.95rem', fontWeight: 700, marginTop: 4, cursor: loading ? 'wait' : 'pointer' }}
190
+ >
191
+ {loading ? 'Verifying...' : 'Unlock Platform →'}
192
+ </button>
193
+ </form>
194
+
195
+ <p style={{ textAlign: 'center', marginTop: 24, fontSize: '0.78rem', color: 'var(--text-3)' }}>
196
+ Don't have an access code?{' '}
197
+ <a href="mailto:oladeji.lawrence@gmail.com" style={{ color: '#60a5fa', textDecoration: 'none' }}>
198
+ Request access →
199
+ </a>
200
+ </p>
201
+ </motion.div>
202
+ </div>
203
+ );
204
+ }
fairvalue-webapp/src/hooks/useUsageLimiter.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+
3
+ const USAGE_KEY = 'fairvalue_evaluation_count';
4
+ const MAX_USES = 3;
5
+
6
+ export function useUsageLimiter() {
7
+ const [useCount, setUseCount] = useState<number>(0);
8
+ const [isLocked, setIsLocked] = useState<boolean>(false);
9
+
10
+ useEffect(() => {
11
+ if (sessionStorage.getItem('fv_access_granted') === 'true') return;
12
+
13
+ const stored = localStorage.getItem(USAGE_KEY);
14
+ const count = stored ? parseInt(stored, 10) : 0;
15
+ setUseCount(count);
16
+ if (count >= MAX_USES) {
17
+ setIsLocked(true);
18
+ }
19
+ }, []);
20
+
21
+ const incrementUsage = () => {
22
+ if (sessionStorage.getItem('fv_access_granted') === 'true') return true;
23
+
24
+ const newCount = useCount + 1;
25
+ setUseCount(newCount);
26
+ localStorage.setItem(USAGE_KEY, newCount.toString());
27
+ if (newCount >= MAX_USES) {
28
+ setIsLocked(true);
29
+ return false; // Tells the caller it just locked
30
+ }
31
+ return true; // Still allowed
32
+ };
33
+
34
+ return { useCount, isLocked, incrementUsage, MAX_USES };
35
+ }
36
+
fairvalue-webapp/src/index.css CHANGED
@@ -1,37 +1,33 @@
1
- @import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,300;0,14..32,400;0,14..32,500;0,14..32,600;0,14..32,700;0,14..32,800;1,14..32,400&display=swap');
2
 
3
- /* ── Design Tokens ───────────────────────────────────────────────────────────── */
4
  :root {
5
- --bg-base: #070711;
6
- --bg-elevated: #0d0d1a;
7
- --bg-elevated-2: #131326;
8
 
9
- --glass-bg: rgba(255,255,255,0.04);
10
  --glass-border: rgba(255,255,255,0.08);
11
- --glass-hover: rgba(255,255,255,0.07);
 
12
 
13
- --green: #00e87a;
14
- --green-dim: rgba(0,232,122,0.10);
15
- --green-glow: 0 0 24px rgba(0,232,122,0.25);
 
 
 
 
16
 
17
- --blue: #4f8ef7;
18
- --blue-dim: rgba(79,142,247,0.10);
19
-
20
- --red: #ff4d6d;
21
- --red-dim: rgba(255,77,109,0.10);
22
-
23
- --gold: #f5a623;
24
- --gold-dim: rgba(245,166,35,0.10);
25
-
26
- --text-1: #eeeef8;
27
- --text-2: #8888aa;
28
- --text-3: #505070;
29
 
30
  --radius: 12px;
31
- --radius-lg: 18px;
32
- --radius-xl: 24px;
33
 
34
- --transition: all 0.2s cubic-bezier(0.4,0,0.2,1);
35
  }
36
 
37
  /* ── Reset ───────────────────────────────────────────────────────────────────── */
@@ -44,32 +40,44 @@ body {
44
  line-height: 1.6;
45
  -webkit-font-smoothing: antialiased;
46
  min-height: 100vh;
 
 
 
 
47
  }
48
  a { color: inherit; text-decoration: none; }
49
  button { font-family: inherit; }
50
 
51
- /* ── Typography ──────────────────────────────────────────────────────────────── */
52
- h1 { font-size: clamp(2rem,5vw,3.6rem); font-weight: 800; line-height: 1.1; letter-spacing: -0.03em; }
53
- h2 { font-size: clamp(1.4rem,3vw,2.1rem); font-weight: 700; letter-spacing: -0.02em; }
54
- h3 { font-size: 1.2rem; font-weight: 600; letter-spacing: -0.01em; }
55
- h4 { font-size: 0.95rem; font-weight: 600; }
 
56
  p { color: var(--text-2); line-height: 1.7; }
57
 
58
  /* ── Layout ──────────────────────────────────────────────────────────────────── */
59
- .container { max-width: 1280px; margin: 0 auto; padding: 0 24px; }
60
- .page { min-height: calc(100vh - 68px); padding: 48px 0 80px; }
61
 
62
- /* ── Glass Card ──────────────────────────────────────────────────────────────── */
63
  .glass {
64
- background: var(--glass-bg);
65
  border: 1px solid var(--glass-border);
66
  border-radius: var(--radius-lg);
67
- backdrop-filter: blur(16px);
 
 
 
68
  transition: var(--transition);
69
  }
70
  .glass:hover {
71
- background: var(--glass-hover);
72
- border-color: rgba(255,255,255,0.12);
 
 
 
 
73
  }
74
  .glass-flat {
75
  background: var(--bg-elevated);
@@ -79,155 +87,168 @@ p { color: var(--text-2); line-height: 1.7; }
79
 
80
  /* ── Buttons ─────────────────────────────────────────────────────────────────── */
81
  .btn {
82
- display: inline-flex; align-items: center; gap: 8px;
83
- padding: 12px 26px;
84
  border-radius: var(--radius);
85
- font-size: 0.92rem; font-weight: 600;
 
86
  cursor: pointer; border: none;
87
  transition: var(--transition);
88
  white-space: nowrap; text-decoration: none;
89
  letter-spacing: -0.01em;
90
  }
91
- .btn:disabled { opacity: 0.45; cursor: not-allowed; }
92
  .btn-primary {
93
- background: var(--green); color: #000;
94
- box-shadow: var(--green-glow);
95
  }
96
  .btn-primary:hover:not(:disabled) {
97
- background: #0ff894;
98
- box-shadow: 0 0 36px rgba(0,232,122,0.45);
99
- transform: translateY(-1px);
 
 
 
 
 
 
 
 
 
100
  }
101
  .btn-ghost {
102
  background: var(--glass-bg); color: var(--text-1);
103
  border: 1px solid var(--glass-border);
104
  }
105
- .btn-ghost:hover { background: var(--glass-hover); }
106
- .btn-lg { padding: 15px 34px; font-size: 1rem; border-radius: var(--radius-lg); }
 
 
 
107
 
108
  /* ── Form Inputs ─────────────────────────────────────────────────────────────── */
109
- .input-group { display: flex; flex-direction: column; gap: 6px; }
110
  .field-label {
111
- font-size: 0.73rem; font-weight: 600;
 
112
  color: var(--text-2);
113
- text-transform: uppercase; letter-spacing: 0.07em;
114
  }
115
  .input, select.input {
116
- background: var(--bg-elevated);
117
  border: 1px solid var(--glass-border);
118
  border-radius: var(--radius);
119
  color: var(--text-1);
120
- font-size: 0.9rem; font-family: inherit;
121
- padding: 10px 14px;
122
  outline: none;
123
  transition: var(--transition);
124
  width: 100%;
125
  }
126
  .input:focus, select.input:focus {
127
- border-color: var(--green);
128
- box-shadow: 0 0 0 3px rgba(0,232,122,0.12);
129
  }
130
  select.input option { background: var(--bg-elevated); }
131
- textarea.input { resize: vertical; min-height: 80px; }
132
 
133
- /* Range slider */
134
  input[type="range"] {
135
  -webkit-appearance: none; appearance: none;
136
- width: 100%; height: 4px; border-radius: 2px;
137
  background: var(--bg-elevated-2); outline: none;
138
- margin: 4px 0;
139
  }
140
  input[type="range"]::-webkit-slider-thumb {
141
  -webkit-appearance: none;
142
- width: 18px; height: 18px; border-radius: 50%;
143
- background: var(--green); cursor: pointer;
144
- box-shadow: 0 0 10px rgba(0,232,122,0.5);
 
145
  }
146
- .range-row { display: flex; justify-content: space-between; font-size: 0.78rem; color: var(--text-3); }
147
- .range-val { font-size: 0.95rem; font-weight: 700; color: var(--green); }
 
148
 
149
- /* ── Metrics ─────────────────────────────────────────────────────────────────── */
150
- .metric { display: flex; flex-direction: column; gap: 3px; }
151
  .metric-label {
152
- font-size: 0.7rem; font-weight: 600;
153
- color: var(--text-2);
154
- text-transform: uppercase; letter-spacing: 0.07em;
155
  }
156
- .metric-value { font-size: 1.8rem; font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
157
- .metric-note { font-size: 0.75rem; color: var(--text-3); }
 
 
158
 
159
  /* ── Badges ──────────────────────────────────────────────────────────────────── */
160
  .badge {
161
- display: inline-flex; align-items: center;
162
- padding: 3px 10px; border-radius: 100px;
163
- font-size: 0.72rem; font-weight: 700; letter-spacing: 0.03em;
 
164
  }
165
- .badge-green { background: var(--green-dim); color: var(--green); }
166
- .badge-blue { background: var(--blue-dim); color: var(--blue); }
167
- .badge-red { background: var(--red-dim); color: var(--red); }
168
- .badge-gold { background: var(--gold-dim); color: var(--gold); }
169
-
170
- /* ── Alerts ──────────────────────────────────────────────────────────────────── */
171
- .alert {
172
- padding: 14px 18px; border-radius: var(--radius);
173
- font-size: 0.88rem; font-weight: 500;
174
- border-left: 3px solid;
175
- }
176
- .alert-success { background: var(--green-dim); border-color: var(--green); color: var(--green); }
177
- .alert-danger { background: var(--red-dim); border-color: var(--red); color: var(--red); }
178
- .alert-info { background: var(--blue-dim); border-color: var(--blue); color: var(--blue); }
179
-
180
- /* ── Divider ─────────────────────────────────────────────────────────────────── */
181
- .divider { height: 1px; background: var(--glass-border); margin: 24px 0; }
182
-
183
- /* ── Animations ──────────────────────────────────────────────────────────────── */
184
- @keyframes fadeInUp {
185
- from { opacity: 0; transform: translateY(20px); }
186
- to { opacity: 1; transform: translateY(0); }
187
- }
188
- @keyframes pulse-ring {
189
- 0%,100% { box-shadow: 0 0 0 0 rgba(0,232,122,0.35); }
190
- 50% { box-shadow: 0 0 0 10px rgba(0,232,122,0); }
191
- }
192
- @keyframes spin {
193
- to { transform: rotate(360deg); }
194
- }
195
- .animate-in { animation: fadeInUp 0.45s ease forwards; }
196
 
197
  /* ── Grid Helpers ────────────────────────────────────────────────────────────── */
198
- .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
199
- .grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 16px; }
200
- .grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 16px; }
201
  @media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
202
  @media (max-width: 768px) { .grid-3, .grid-4 { grid-template-columns: 1fr 1fr; } }
203
  @media (max-width: 500px) { .grid-3, .grid-4 { grid-template-columns: 1fr; } }
204
 
205
- /* ── Spinner ���────────────────────────────────────────────────────────────────── */
206
- .spinner {
207
- display: inline-block;
208
- width: 18px; height: 18px;
209
- border: 2px solid rgba(255,255,255,0.15);
210
- border-top-color: var(--green);
211
- border-radius: 50%;
212
- animation: spin 0.7s linear infinite;
213
- vertical-align: middle;
214
- }
215
-
216
- /* ── Scrollbar ───────────────────────────────────────────────────────────────── */
217
- ::-webkit-scrollbar { width: 5px; }
218
- ::-webkit-scrollbar-track { background: var(--bg-base); }
219
- ::-webkit-scrollbar-thumb { background: var(--bg-elevated-2); border-radius: 3px; }
220
-
221
- /* ── Gradient Text ───────────────────────────────────────────────────────────── */
222
  .gradient-text {
223
- background: linear-gradient(135deg, var(--green) 0%, var(--blue) 100%);
 
 
 
 
 
224
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
225
  background-clip: text;
226
  }
227
 
228
- /* ── Sentiment Bar ───────────────────────────────────────────────────────────── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  .sentiment-bar-track {
230
- height: 7px; border-radius: 4px;
231
  background: var(--bg-elevated-2);
232
  overflow: hidden;
233
  }
@@ -235,3 +256,48 @@ input[type="range"]::-webkit-slider-thumb {
235
  height: 100%; border-radius: 4px;
236
  transition: width 0.6s cubic-bezier(0.4,0,0.2,1);
237
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Inter:wght@400;500;600&display=swap');
2
 
3
+ /* ── UI/UX Pro Max: Financial Cinematic Dashboard Tokens ───────────────────────── */
4
  :root {
5
+ --bg-base: #04040a;
6
+ --bg-elevated: #090913;
7
+ --bg-elevated-2: #121222;
8
 
9
+ --glass-bg: rgba(255,255,255,0.03);
10
  --glass-border: rgba(255,255,255,0.08);
11
+ --glass-border-hi: rgba(255,255,255,0.15);
12
+ --glass-hover: rgba(255,255,255,0.06);
13
 
14
+ /* Semantic Financial Colors per Guidelines */
15
+ --profit-color: #22C55E; /* Trust Green */
16
+ --loss-color: #EF4444; /* Alert Red */
17
+ --neutral-color: #6B7280;
18
+
19
+ --accent-blue: #3B82F6;
20
+ --accent-glow: 0 0 30px rgba(59, 130, 246, 0.25);
21
 
22
+ --text-1: #ffffff;
23
+ --text-2: #94a3b8;
24
+ --text-3: #64748b;
 
 
 
 
 
 
 
 
 
25
 
26
  --radius: 12px;
27
+ --radius-lg: 20px;
28
+ --radius-xl: 28px;
29
 
30
+ --transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
31
  }
32
 
33
  /* ── Reset ───────────────────────────────────────────────────────────────────── */
 
40
  line-height: 1.6;
41
  -webkit-font-smoothing: antialiased;
42
  min-height: 100vh;
43
+ /* Deep cinematic radial gradient background */
44
+ background-image: radial-gradient(circle at 50% 0%, rgba(59, 130, 246, 0.08) 0%, transparent 60%);
45
+ background-repeat: no-repeat;
46
+ background-attachment: fixed;
47
  }
48
  a { color: inherit; text-decoration: none; }
49
  button { font-family: inherit; }
50
 
51
+ /* ── Typography (Display vs. Body) ───────────────────────────────────────────── */
52
+ h1, h2, h3, h4, .display-font { font-family: 'Outfit', sans-serif; }
53
+ h1 { font-size: clamp(2.5rem,6vw,4.5rem); font-weight: 800; line-height: 1.05; letter-spacing: -0.04em; }
54
+ h2 { font-size: clamp(1.8rem,4vw,2.8rem); font-weight: 700; letter-spacing: -0.03em; }
55
+ h3 { font-size: 1.3rem; font-weight: 600; letter-spacing: -0.02em; }
56
+ h4 { font-size: 1rem; font-weight: 600; }
57
  p { color: var(--text-2); line-height: 1.7; }
58
 
59
  /* ── Layout ──────────────────────────────────────────────────────────────────── */
60
+ .container { max-width: 1200px; margin: 0 auto; padding: 0 28px; }
61
+ .page { min-height: calc(100vh - 68px); padding: 56px 0 100px; }
62
 
63
+ /* ── UI Pro Max Glass Card (Rim Lighting & Shadow layers) ────────────────────── */
64
  .glass {
65
+ background: linear-gradient(145deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.01) 100%);
66
  border: 1px solid var(--glass-border);
67
  border-radius: var(--radius-lg);
68
+ backdrop-filter: blur(24px);
69
+ box-shadow:
70
+ 0 4px 24px -1px rgba(0,0,0,0.4),
71
+ inset 0 1px 0 0 rgba(255,255,255,0.1); /* Top rim light */
72
  transition: var(--transition);
73
  }
74
  .glass:hover {
75
+ background: linear-gradient(145deg, rgba(255,255,255,0.07) 0%, rgba(255,255,255,0.02) 100%);
76
+ border-color: var(--glass-border-hi);
77
+ transform: translateY(-2px);
78
+ box-shadow:
79
+ 0 12px 32px -4px rgba(0,0,0,0.5),
80
+ inset 0 1px 0 0 rgba(255,255,255,0.15);
81
  }
82
  .glass-flat {
83
  background: var(--bg-elevated);
 
87
 
88
  /* ── Buttons ─────────────────────────────────────────────────────────────────── */
89
  .btn {
90
+ display: inline-flex; align-items: center; gap: 8px; justify-content: center;
91
+ padding: 14px 28px;
92
  border-radius: var(--radius);
93
+ font-family: 'Outfit', sans-serif;
94
+ font-size: 1.05rem; font-weight: 600;
95
  cursor: pointer; border: none;
96
  transition: var(--transition);
97
  white-space: nowrap; text-decoration: none;
98
  letter-spacing: -0.01em;
99
  }
100
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
101
  .btn-primary {
102
+ background: var(--text-1); color: #000;
103
+ box-shadow: 0 4px 14px 0 rgba(255,255,255,0.3);
104
  }
105
  .btn-primary:hover:not(:disabled) {
106
+ background: #f0f0f0;
107
+ box-shadow: 0 6px 20px rgba(255,255,255,0.4);
108
+ transform: translateY(-2px) scale(1.02);
109
+ }
110
+ .btn-secondary {
111
+ background: linear-gradient(135deg, var(--accent-blue), #2563eb);
112
+ color: #fff;
113
+ box-shadow: var(--accent-glow);
114
+ }
115
+ .btn-secondary:hover:not(:disabled) {
116
+ box-shadow: 0 0 40px rgba(59, 130, 246, 0.4);
117
+ transform: translateY(-2px);
118
  }
119
  .btn-ghost {
120
  background: var(--glass-bg); color: var(--text-1);
121
  border: 1px solid var(--glass-border);
122
  }
123
+ .btn-ghost:hover:not(:disabled) {
124
+ background: var(--glass-hover);
125
+ border-color: var(--glass-border-hi);
126
+ }
127
+ .btn-lg { padding: 18px 40px; font-size: 1.15rem; border-radius: var(--radius-lg); }
128
 
129
  /* ── Form Inputs ─────────────────────────────────────────────────────────────── */
130
+ .input-group { display: flex; flex-direction: column; gap: 8px; }
131
  .field-label {
132
+ font-family: 'Outfit', sans-serif;
133
+ font-size: 0.8rem; font-weight: 600;
134
  color: var(--text-2);
135
+ text-transform: uppercase; letter-spacing: 0.08em;
136
  }
137
  .input, select.input {
138
+ background: #0a0a14;
139
  border: 1px solid var(--glass-border);
140
  border-radius: var(--radius);
141
  color: var(--text-1);
142
+ font-size: 1rem; font-family: inherit;
143
+ padding: 12px 16px;
144
  outline: none;
145
  transition: var(--transition);
146
  width: 100%;
147
  }
148
  .input:focus, select.input:focus {
149
+ border-color: var(--accent-blue);
150
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
151
  }
152
  select.input option { background: var(--bg-elevated); }
153
+ textarea.input { resize: vertical; min-height: 100px; }
154
 
155
+ /* Range slider tracking */
156
  input[type="range"] {
157
  -webkit-appearance: none; appearance: none;
158
+ width: 100%; height: 6px; border-radius: 3px;
159
  background: var(--bg-elevated-2); outline: none;
160
+ margin: 6px 0;
161
  }
162
  input[type="range"]::-webkit-slider-thumb {
163
  -webkit-appearance: none;
164
+ width: 20px; height: 20px; border-radius: 50%;
165
+ background: var(--text-1); cursor: pointer;
166
+ box-shadow: 0 0 12px rgba(255,255,255,0.5);
167
+ transition: transform 0.1s;
168
  }
169
+ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
170
+ .range-row { display: flex; justify-content: space-between; font-size: 0.8rem; color: var(--text-3); font-weight: 500;}
171
+ .range-val { font-size: 1.05rem; font-weight: 700; color: var(--text-1); font-family: 'Outfit', sans-serif;}
172
 
173
+ /* ── Financial Metrics ───────────────────────────────────────────────────────── */
174
+ .metric { display: flex; flex-direction: column; gap: 4px; }
175
  .metric-label {
176
+ font-size: 0.75rem; font-weight: 600;
177
+ color: var(--text-2); font-family: 'Outfit', sans-serif;
178
+ text-transform: uppercase; letter-spacing: 0.05em;
179
  }
180
+ .metric-value { font-size: 2.2rem; font-weight: 800; letter-spacing: -0.04em; line-height: 1; font-family: 'Outfit', sans-serif; }
181
+ .metric-note { font-size: 0.8rem; color: var(--text-3); }
182
+ .value-up { color: var(--profit-color); }
183
+ .value-down { color: var(--loss-color); }
184
 
185
  /* ── Badges ──────────────────────────────────────────────────────────────────── */
186
  .badge {
187
+ display: inline-flex; align-items: center; gap: 6px;
188
+ padding: 6px 14px; border-radius: 100px;
189
+ font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em;
190
+ font-family: 'Outfit', sans-serif; text-transform: uppercase;
191
  }
192
+ .badge-blue { background: rgba(59,130,246,0.15); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); }
193
+ .badge-green { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
194
+ .badge-red { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); }
195
+ .badge-warning { background: rgba(245,158,11,0.15); color: #fbbf24; border: 1px solid rgba(245,158,11,0.3); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
  /* ── Grid Helpers ────────────────────────────────────────────────────────────── */
198
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
199
+ .grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 24px; }
200
+ .grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 24px; }
201
  @media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
202
  @media (max-width: 768px) { .grid-3, .grid-4 { grid-template-columns: 1fr 1fr; } }
203
  @media (max-width: 500px) { .grid-3, .grid-4 { grid-template-columns: 1fr; } }
204
 
205
+ /* ── Cinematic Gradient Text ─────────────────────────────────────────────────── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  .gradient-text {
207
+ background: linear-gradient(135deg, #fff 0%, #a1a1aa 100%);
208
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
209
+ background-clip: text;
210
+ }
211
+ .gradient-accent {
212
+ background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
213
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
214
  background-clip: text;
215
  }
216
 
217
+ /* ── Modal Portal / Background Blur Tracker ──────────────────────────────────── */
218
+ .modal-overlay {
219
+ position: fixed; inset: 0; z-index: 999;
220
+ background: rgba(0,0,0,0.6);
221
+ backdrop-filter: blur(12px);
222
+ display: flex; align-items: center; justify-content: center;
223
+ padding: 24px;
224
+ }
225
+ .modal-content {
226
+ width: 100%; max-width: 480px;
227
+ background: #04040a;
228
+ border-radius: var(--radius-xl);
229
+ border: 1px solid var(--glass-border-hi);
230
+ padding: 40px;
231
+ box-shadow: 0 24px 64px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,255,255,0.1);
232
+ position: relative;
233
+ overflow: hidden;
234
+ }
235
+ .modal-content::before {
236
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
237
+ background: linear-gradient(90deg, var(--accent-blue), #22d3ee);
238
+ }
239
+
240
+ /* Tooltip Customization for High Contrast */
241
+ .recharts-tooltip-wrapper { z-index: 10 !important; }
242
+ .recharts-default-tooltip {
243
+ background-color: var(--bg-elevated) !important;
244
+ border: 1px solid var(--glass-border-hi) !important;
245
+ border-radius: 8px !important;
246
+ color: var(--text-1) !important;
247
+ box-shadow: 0 12px 24px rgba(0,0,0,0.5) !important;
248
+ }
249
+
250
  .sentiment-bar-track {
251
+ height: 8px; border-radius: 4px;
252
  background: var(--bg-elevated-2);
253
  overflow: hidden;
254
  }
 
256
  height: 100%; border-radius: 4px;
257
  transition: width 0.6s cubic-bezier(0.4,0,0.2,1);
258
  }
259
+
260
+ /* ── Print Styles for PDF Generation ─────────────────────────────────────────── */
261
+ @media print {
262
+ /* Hide all normal UI elements */
263
+ body > #root > .page > .container {
264
+ display: none !important;
265
+ }
266
+ .modal-overlay, .access-modal, .no-print, nav {
267
+ display: none !important;
268
+ }
269
+
270
+ @page {
271
+ size: A4;
272
+ margin: 15mm;
273
+ }
274
+
275
+ /* Reset background and heights for clean printing */
276
+ body, html, #root, .page {
277
+ background: #ffffff !important;
278
+ background-image: none !important;
279
+ color: #0f172a !important;
280
+ margin: 0 !important;
281
+ padding: 0 !important;
282
+ height: auto !important;
283
+ min-height: 0 !important;
284
+ }
285
+
286
+ /* Make report visible and properly sized */
287
+ .printable-report {
288
+ display: block !important;
289
+ position: static !important;
290
+ width: auto !important;
291
+ background: white !important;
292
+ box-shadow: none !important;
293
+ padding: 0 !important;
294
+ margin: 0 !important;
295
+ min-height: 0 !important;
296
+ }
297
+
298
+ /* Force background colors to print (Chrome/Safari) */
299
+ * {
300
+ -webkit-print-color-adjust: exact !important;
301
+ print-color-adjust: exact !important;
302
+ }
303
+ }
fairvalue-webapp/src/pages/AboutDeveloper.tsx ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from 'framer-motion'
2
+ import { Mail, Phone, ExternalLink, Briefcase, GraduationCap, Code, Database, BrainCircuit, LineChart } from 'lucide-react'
3
+
4
+ const PROJECTS = [
5
+ {
6
+ title: 'FairValue Player Estimator',
7
+ tags: ['XGBoost', 'SHAP', 'React'],
8
+ desc: 'Advanced sport analytics platform deploying Hedonic Pricing models and NLP transfer intelligence to compute PSR compliance and mitigate winner\'s curse.',
9
+ link: '#',
10
+ },
11
+ {
12
+ title: 'WHO ESPEN Nigeria Portal',
13
+ tags: ['Vercel Edge', 'Leaflet', 'Node.js'],
14
+ desc: 'A premium analytics platform deployed for international public health monitoring. Engineered real-time supply chain latency algorithms bridging SQL logic with JavaScript, enveloped in a stunning glassmorphism geospatial UX.',
15
+ link: 'https://who-espen-nigeria-ntd.vercel.app/',
16
+ },
17
+ {
18
+ title: 'LinkyGen Intelligence',
19
+ tags: ['LangGraph', 'Agentic AI'],
20
+ desc: 'Advanced AI application utilizing a multi-agentic LangGraph architecture to streamline high-quality dynamic content pipelines.',
21
+ link: 'https://linkygen.streamlit.app/',
22
+ },
23
+ {
24
+ title: 'SESA AI Optimization',
25
+ tags: ['Multi-Agent', 'Python'],
26
+ desc: 'Stochastic optimization engine built on Agentic AI orchestration to minimize smart building electricity via BESS arbitrage.',
27
+ link: 'https://sesa-energy.streamlit.app/',
28
+ },
29
+ {
30
+ title: 'Stratos Content Empire',
31
+ tags: ['LangGraph', 'Streamlit'],
32
+ desc: 'Strategic AI marketing tool powered by LangGraph multi-agent orchestration mapping pinpoint audience intent.',
33
+ link: 'https://stratos-content.streamlit.app/',
34
+ },
35
+ {
36
+ title: 'XGen Studio',
37
+ tags: ['CrewAI', 'LangChain'],
38
+ desc: 'Viral writing assistant leveraging a multi-agent LLM workflow to scale brand growth via highly-humanized narratives.',
39
+ link: 'https://xgenstudio.streamlit.app/',
40
+ },
41
+ {
42
+ title: 'Finstratz Analytics',
43
+ tags: ['TensorFlow', 'Python'],
44
+ desc: 'Python-based financial suite utilizing deep learning (TensorFlow) to forecast stock trends and price variations.',
45
+ link: 'https://finstratz.streamlit.app/',
46
+ },
47
+ {
48
+ title: 'Stanbic IBTC Simulator',
49
+ tags: ['Financial Modeling', 'Data Science'],
50
+ desc: 'App generating robust performance forecasts across complex Money Market portfolios.',
51
+ link: 'https://stanbic-ibtc-portfolio-simulation.streamlit.app/',
52
+ },
53
+ ]
54
+
55
+ export default function AboutDeveloper() {
56
+ return (
57
+ <div className="page" style={{ overflow: 'hidden' }}>
58
+
59
+ {/* ── Cinematic Hero ────────────────────────────────────────────────── */}
60
+ <section className="container" style={{ padding: '40px 24px 60px', borderBottom: '1px solid var(--glass-border)' }}>
61
+ <motion.div
62
+ initial={{ opacity: 0, y: 30 }}
63
+ animate={{ opacity: 1, y: 0 }}
64
+ transition={{ duration: 0.6, ease: "easeOut" }}
65
+ style={{ display: 'grid', gridTemplateColumns: 'minmax(300px, 1fr) auto', gap: '48px', alignItems: 'center' }}
66
+ >
67
+ <div>
68
+ <div style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', marginBottom: '24px', padding: '6px 16px', background: 'rgba(34,197,94,0.1)', border: '1px solid rgba(34,197,94,0.2)', borderRadius: '100px' }}>
69
+ <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#22C55E', animation: 'pulse-ring 2s infinite' }} />
70
+ <span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#4ade80', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
71
+ Open to Mid/Senior Roles Globally
72
+ </span>
73
+ </div>
74
+
75
+ <h1 className="display-font" style={{ marginBottom: '16px', fontSize: ' clamp(2.5rem, 5vw, 4rem)' }}>Lawrence Oladeji</h1>
76
+ <h2 style={{ fontSize: '1.4rem', color: 'var(--accent-blue)', marginBottom: '24px', fontWeight: 600 }}>
77
+ AI-Native Data Scientist & Sports Pricing Strategist
78
+ </h2>
79
+
80
+ <p style={{ maxWidth: '720px', fontSize: '1.1rem', lineHeight: 1.8, color: 'var(--text-2)', marginBottom: '32px' }}>
81
+ I am an AI-Native Data Associate with 3+ years of experience transforming complex datasets into strategic insights for executive decision-making.
82
+ My expertise encompasses sports analytics, hedonic pricing models, AI-driven transfer logic, and traditional Business Intelligence heavily augmented
83
+ by deep expertise in Generative AI and LLM orchestration (LangGraph, CrewAI). I am profoundly passionate about architecting scalable, data-driven solutions at the intersection of quantitative sport economics and Agentic AI.
84
+ </p>
85
+
86
+ <div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
87
+ <a href="mailto:oladeji.lawrence@gmail.com" className="btn btn-secondary">
88
+ <Mail size={18} /> oladeji.lawrence@gmail.com
89
+ </a>
90
+ <a href="https://github.com/Lawrencium-103" target="_blank" rel="noreferrer" className="btn btn-ghost">
91
+ <Code size={18} /> GitHub: Lawrencium-103
92
+ </a>
93
+ <span className="btn btn-ghost" style={{ cursor: 'default' }}>
94
+ <Phone size={18} /> +234 903 881 9790
95
+ </span>
96
+ </div>
97
+ </div>
98
+ </motion.div>
99
+ </section>
100
+
101
+ {/* ── Expertise Grid ────────────────────────────────────────────────── */}
102
+ <section className="container" style={{ padding: '80px 24px' }}>
103
+ <h2 className="display-font" style={{ marginBottom: '40px', textAlign: 'center' }}>Core Competencies</h2>
104
+ <div className="grid-3">
105
+ <motion.div whileHover={{ y: -5 }} className="glass" style={{ padding: '32px' }}>
106
+ <LineChart size={32} color="#f5a623" style={{ marginBottom: '20px' }} />
107
+ <h3 style={{ marginBottom: '12px' }}>Sports Pricing & BI</h3>
108
+ <p style={{ fontSize: '0.9rem', color: 'var(--text-2)', marginBottom: '16px' }}>
109
+ Transforming raw datasets into executive C-Suite narratives. Expert in translating complex valuations (Transfer Estimators) and multi-million-dollar portfolios into interactive command centers.
110
+ </p>
111
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
112
+ <span className="badge badge-gold">Power BI / DAX</span>
113
+ <span className="badge badge-gold">Tableau</span>
114
+ <span className="badge badge-gold">XGBoost / SHAP</span>
115
+ </div>
116
+ </motion.div>
117
+
118
+ <motion.div whileHover={{ y: -5 }} className="glass" style={{ padding: '32px' }}>
119
+ <Database size={32} color="#22c55e" style={{ marginBottom: '20px' }} />
120
+ <h3 style={{ marginBottom: '12px' }}>Data Engineering</h3>
121
+ <p style={{ fontSize: '0.9rem', color: 'var(--text-2)', marginBottom: '16px' }}>
122
+ Architecting scalable pipelines and robust data models. Proven track record dropping query latency by 15% across global health databanks using optimized SQL architectures.
123
+ </p>
124
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
125
+ <span className="badge badge-green">PostgreSQL</span>
126
+ <span className="badge badge-green">BigQuery</span>
127
+ <span className="badge badge-green">Airflow ETL</span>
128
+ </div>
129
+ </motion.div>
130
+
131
+ <motion.div whileHover={{ y: -5 }} className="glass" style={{ padding: '32px' }}>
132
+ <BrainCircuit size={32} color="#3b82f6" style={{ marginBottom: '20px' }} />
133
+ <h3 style={{ marginBottom: '12px' }}>Agentic AI & LLMs</h3>
134
+ <p style={{ fontSize: '0.9rem', color: 'var(--text-2)', marginBottom: '16px' }}>
135
+ Designing autonomous workflows orchestrating complex data routing via LLMs. Pioneering the integration of multi-agentic reasoning frameworks into traditional data domains.
136
+ </p>
137
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
138
+ <span className="badge badge-blue">LangGraph</span>
139
+ <span className="badge badge-blue">CrewAI</span>
140
+ <span className="badge badge-blue">Python</span>
141
+ </div>
142
+ </motion.div>
143
+ </div>
144
+ </section>
145
+
146
+ {/* ── Professional Blueprint ────────────────────────────────────────── */}
147
+ <section style={{ background: 'rgba(255,255,255,0.01)', borderTop: '1px solid var(--glass-border)', borderBottom: '1px solid var(--glass-border)' }}>
148
+ <div className="container" style={{ padding: '80px 24px' }}>
149
+ <h2 className="display-font" style={{ marginBottom: '48px', color: 'var(--text-1)' }}>Professional Blueprint & Background</h2>
150
+
151
+ <div className="grid-2">
152
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
153
+
154
+ <div className="glass-flat" style={{ padding: '32px', position: 'relative' }}>
155
+ <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: '4px', background: 'var(--accent-blue)', borderTopLeftRadius: 'var(--radius-lg)', borderBottomLeftRadius: 'var(--radius-lg)' }} />
156
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
157
+ <Briefcase size={20} color="var(--accent-blue)" />
158
+ <h3 style={{ margin: 0 }}>Data Associate</h3>
159
+ </div>
160
+ <div style={{ fontSize: '0.85rem', color: 'var(--text-3)', marginBottom: '20px', fontWeight: 600 }}>May 2024 – Present</div>
161
+ <ul style={{ paddingLeft: '20px', color: 'var(--text-2)', fontSize: '0.95rem', display: 'flex', flexDirection: 'column', gap: '8px' }}>
162
+ <li>Designed and deployed executive-level Power BI dashboards for C-Suite stakeholders, optimizing multi-million-dollar portfolios.</li>
163
+ <li>Engineered complex SQL data models on BigQuery & PostgreSQL, cutting query latency by 15% across 50+ key indicators.</li>
164
+ <li>Centralized 56,000+ data points across 1,450+ indicators from world-class sources (World Bank, WHO) to ensure 100% regulatory reporting accuracy.</li>
165
+ <li>Constructed AI content frameworks utilizing multi-agent reasoning to automate reporting, saving significant overhead blocks.</li>
166
+ </ul>
167
+ </div>
168
+
169
+ <div className="glass-flat" style={{ padding: '32px', position: 'relative' }}>
170
+ <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: '4px', background: 'var(--text-3)', borderTopLeftRadius: 'var(--radius-lg)', borderBottomLeftRadius: 'var(--radius-lg)' }} />
171
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
172
+ <Briefcase size={20} color="var(--text-3)" />
173
+ <h3 style={{ margin: 0 }}>Junior BI / Data Operations Analyst</h3>
174
+ </div>
175
+ <div style={{ fontSize: '0.85rem', color: 'var(--text-3)', marginBottom: '20px', fontWeight: 600 }}>2019 – April 2024</div>
176
+ <ul style={{ paddingLeft: '20px', color: 'var(--text-2)', fontSize: '0.95rem', display: 'flex', flexDirection: 'column', gap: '8px' }}>
177
+ <li>Synthesized business requirements into actionable data models for E-mobility.</li>
178
+ <li>Executed statistical modeling predicting demand, cutting lifecycles by 20%.</li>
179
+ <li>Identified high ROI cost-savings via exploratory spatial geospatial analysis.</li>
180
+ </ul>
181
+ </div>
182
+ </div>
183
+
184
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
185
+ <h3 style={{ display: 'flex', alignItems: 'center', gap: '8px', paddingBottom: '12px', borderBottom: '1px solid var(--glass-border)' }}>
186
+ <GraduationCap size={22} color="var(--green)" /> Academic Background
187
+ </h3>
188
+
189
+ <div>
190
+ <h4 style={{ fontSize: '1.1rem', marginBottom: '4px' }}>MSc in Mechanical Engineering</h4>
191
+ <div style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>University of Ibadan · 2023 - 2024</div>
192
+ </div>
193
+
194
+ <div>
195
+ <h4 style={{ fontSize: '1.1rem', marginBottom: '4px' }}>BSc in Mechanical Engineering (2:1 Honors)</h4>
196
+ <div style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>Federal Univ. of Agriculture, Abeokuta · CGPA: 3.99/5.0</div>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </section>
202
+
203
+ {/* ── Portfolio Grid ────────────────────────────────────────────────── */}
204
+ <section className="container" style={{ padding: '80px 24px' }}>
205
+ <div style={{ textAlign: 'center', marginBottom: '56px' }}>
206
+ <h2 className="display-font" style={{ marginBottom: '16px' }}>Agentic & Analytics Portfolio</h2>
207
+ <p style={{ maxWidth: '600px', margin: '0 auto' }}>A selection of high-impact production dashboards and multi-agent AI ecosystems built across multiple domains.</p>
208
+ </div>
209
+
210
+ <div className="grid-2">
211
+ {PROJECTS.map((proj, i) => (
212
+ <motion.a
213
+ href={proj.link}
214
+ target="_blank"
215
+ rel="noreferrer"
216
+ key={i}
217
+ whileHover={{ y: -4, borderColor: 'rgba(255,255,255,0.2)' }}
218
+ className="glass"
219
+ style={{ padding: '32px', display: 'flex', flexDirection: 'column', cursor: proj.link === '#' ? 'default' : 'pointer', textDecoration: 'none' }}
220
+ >
221
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
222
+ <h3 className="display-font" style={{ color: 'var(--text-1)', fontSize: '1.4rem' }}>{proj.title}</h3>
223
+ {proj.link !== '#' && <ExternalLink size={20} color="var(--text-3)" />}
224
+ </div>
225
+
226
+ <p style={{ fontSize: '0.95rem', color: 'var(--text-2)', lineHeight: 1.6, flexGrow: 1, marginBottom: '24px' }}>
227
+ {proj.desc}
228
+ </p>
229
+
230
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
231
+ {proj.tags.map(tag => (
232
+ <span key={tag} style={{ padding: '4px 10px', fontSize: '0.75rem', background: 'var(--bg-elevated)', border: '1px solid var(--glass-border)', borderRadius: '6px', color: 'var(--text-1)' }}>
233
+ {tag}
234
+ </span>
235
+ ))}
236
+ </div>
237
+ </motion.a>
238
+ ))}
239
+ </div>
240
+ </section>
241
+
242
+ </div>
243
+ )
244
+ }
fairvalue-webapp/src/pages/Estimator.tsx CHANGED
@@ -1,8 +1,14 @@
1
- import { useState } from 'react'
2
  import {
3
- BarChart, Bar, Cell, XAxis, YAxis, Tooltip,
4
  ResponsiveContainer, ReferenceLine,
5
  } from 'recharts'
 
 
 
 
 
 
6
 
7
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
8
 
@@ -50,29 +56,29 @@ function Gauge({ asking, hardCap }: { asking: number; hardCap: number }) {
50
  {/* Track */}
51
  <path d={arc(-180,0)} fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth={18} strokeLinecap="round"/>
52
  {/* Green zone: 0 → cap */}
53
- <path d={arc(-180, capDeg)} fill="none" stroke="var(--green)" strokeWidth={18} strokeLinecap="round" opacity={0.85}/>
54
  {/* Red zone: cap → asking (only if over) */}
55
  {isOver && (
56
- <path d={arc(capDeg, Math.min(askDeg, 0))} fill="none" stroke="var(--red)" strokeWidth={18} strokeLinecap="round" opacity={0.85}/>
57
  )}
58
  {/* Needle */}
59
  <line x1={cx} y1={cy} x2={pt(askDeg).x} y2={pt(askDeg).y} stroke="white" strokeWidth={2.5} strokeLinecap="round"/>
60
  <circle cx={cx} cy={cy} r={5} fill="white"/>
61
- {/* Cap label */}
62
  <text x={pt(capDeg).x + (capDeg < -90 ? -6 : 6)} y={pt(capDeg).y - 6}
63
  textAnchor={capDeg < -90 ? 'end' : 'start'}
64
- fill="var(--green)" fontSize={9} fontWeight={600}>
65
- Hard Cap £{hardCap.toFixed(0)}m
66
  </text>
67
  {/* Center: asking */}
68
  <text x={cx} y={cy + 22} textAnchor="middle" fill="white" fontSize={22} fontWeight={800}>
69
  £{asking.toFixed(0)}m
70
  </text>
71
  <text x={cx} y={cy + 40} textAnchor="middle"
72
- fill={isOver ? 'var(--red)' : 'var(--green)'} fontSize={12} fontWeight={600}>
73
  {isOver
74
- ? `▲ £${(asking - hardCap).toFixed(1)}m OVER CAP`
75
- : `✓ Within Hard Cap`}
76
  </text>
77
  {/* Scale ticks */}
78
  {[0, 0.25, 0.5, 0.75, 1].map(f => {
@@ -94,13 +100,23 @@ function ShapChart({ data }: { data: { feature: string; impact: number }[] }) {
94
  <YAxis type="category" dataKey="feature" tick={{ fill:'var(--text-2)', fontSize:11 }} width={140} axisLine={false} tickLine={false}/>
95
  <Tooltip
96
  cursor={{ fill:'rgba(255,255,255,0.04)' }}
97
- contentStyle={{ background:'var(--bg-elevated)', border:'1px solid var(--glass-border)', borderRadius:8, fontSize:12 }}
98
- formatter={(v: number) => [v > 0 ? `+${v.toFixed(3)}` : v.toFixed(3), 'SHAP Impact']}
 
 
99
  />
100
  <ReferenceLine x={0} stroke="rgba(255,255,255,0.12)" strokeWidth={1}/>
101
  <Bar dataKey="impact" radius={3}>
 
 
 
 
 
 
 
 
102
  {data.map((entry, i) => (
103
- <Cell key={i} fill={entry.impact >= 0 ? 'var(--green)' : 'var(--red)'} fillOpacity={0.85}/>
104
  ))}
105
  </Bar>
106
  </BarChart>
@@ -111,7 +127,7 @@ function ShapChart({ data }: { data: { feature: string; impact: number }[] }) {
111
  // ── NLP Sentiment Score ───────────────────────────────────────────────────────
112
  function SentimentScore({ label, value, icon }: { label: string; value: number; icon: string }) {
113
  const pct = Math.round(((value + 1) / 2) * 100)
114
- const color = value > 0.1 ? 'var(--green)' : value < -0.1 ? 'var(--red)' : 'var(--blue)'
115
  return (
116
  <div>
117
  <div style={{ display:'flex', justifyContent:'space-between', marginBottom:6 }}>
@@ -127,8 +143,62 @@ function SentimentScore({ label, value, icon }: { label: string; value: number;
127
  )
128
  }
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  // ── Main Estimator Page ───────────────────────────────────────────────────────
131
  export default function Estimator() {
 
 
132
  const [form, setForm] = useState({
133
  selected_name: '',
134
  current_club: '',
@@ -146,8 +216,26 @@ export default function Estimator() {
146
 
147
  const set = (k: string, v: string | number) => setForm(f => ({ ...f, [k]: v }))
148
 
149
- const handleSubmit = async () => {
150
- if (!form.selected_name.trim()) { setError('Player name is required.'); return }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  setLoading(true); setError(null); setResult(null)
152
  try {
153
  const res = await fetch(`${API_URL}/api/evaluate`, {
@@ -167,13 +255,20 @@ export default function Estimator() {
167
  }
168
  }
169
 
 
 
 
 
 
170
  const L = result?.ledger
171
 
172
  return (
173
  <div className="page">
174
- <div className="container">
 
 
175
 
176
- {/* Header */}
177
  <div style={{ marginBottom:36 }}>
178
  <span className="badge badge-green" style={{ marginBottom:10, display:'inline-flex' }}>AI Valuation Engine</span>
179
  <h1 style={{ marginBottom:8 }}>Strategic Transfer Estimator</h1>
@@ -273,7 +368,7 @@ export default function Estimator() {
273
  <button className="btn btn-primary" style={{ width:'100%', justifyContent:'center', padding:14 }}
274
  onClick={handleSubmit} disabled={loading}>
275
  {loading
276
- ? <><span className="spinner"/> Fetching live intel… (15–30s)</>
277
  : '⚡ Calculate Hard Cap'}
278
  </button>
279
 
@@ -291,8 +386,8 @@ export default function Estimator() {
291
 
292
  {loading && (
293
  <div className="glass" style={{ padding:48, textAlign:'center' }}>
294
- <div className="spinner" style={{ width:40, height:40, margin:'0 auto 20px' }}/>
295
- <p>Running ML inference + scraping live market signals…</p>
296
  <p style={{ fontSize:'0.8rem', color:'var(--text-3)', marginTop:8 }}>
297
  Live DDGS intel can take 15–30 seconds first time.
298
  </p>
@@ -300,13 +395,25 @@ export default function Estimator() {
300
  )}
301
 
302
  {result && L && (
303
- <div className="animate-in" style={{ display:'flex', flexDirection:'column', gap:18 }}>
 
 
 
 
 
 
304
 
305
  {/* Verdict alert */}
306
- <div className={`alert ${form.asking_price > L.hard_cap ? 'alert-danger' : 'alert-success'}`}>
307
- {form.asking_price > L.hard_cap
308
- ? `⚠️ OVERPAY RISK — Asking price £${form.asking_price}m exceeds our hard cap of £${L.hard_cap.toFixed(1)}m by £${(form.asking_price - L.hard_cap).toFixed(1)}m.`
309
- : ` FAIR DEAL — Asking price £${form.asking_price}m is within the £${L.hard_cap.toFixed(1)}m hard cap. Proceed with confidence.`}
 
 
 
 
 
 
310
  </div>
311
 
312
  {/* Gauge + Ledger */}
@@ -320,16 +427,16 @@ export default function Estimator() {
320
  <div style={{ display:'flex', flexDirection:'column', gap:16 }}>
321
  <div>
322
  <div className="metric-label">Intrinsic Performance Value</div>
323
- <div className="metric-value" style={{ color:'var(--green)' }}>£{L.intrinsic_performance_value.toFixed(1)}m</div>
324
  <span className="badge badge-green" style={{ marginTop:4 }}>{L.category}</span>
325
  </div>
326
  <div className="divider" style={{ margin:'8px 0' }}/>
327
  <div>
328
- <div className="metric-label">Age & Contract Depreciation</div>
329
- <div className="metric-value" style={{ color: L.depreciation < 0 ? 'var(--red)' : 'var(--blue)' }}>
330
  {L.depreciation >= 0 ? '+' : ''}£{L.depreciation.toFixed(1)}m
331
  </div>
332
- <span className="badge badge-blue" style={{ marginTop:4 }}>SHAP Calculated Penalty</span>
333
  </div>
334
  <div className="divider" style={{ margin:'8px 0' }}/>
335
  <div>
@@ -338,13 +445,13 @@ export default function Estimator() {
338
  </div>
339
  <div>
340
  <div className="metric-label">NLP Multiplier</div>
341
- <div className="metric-value" style={{ color: L.external_multiplier > 1 ? 'var(--green)' : 'var(--red)' }}>
342
  ×{L.external_multiplier.toFixed(3)}
343
  </div>
344
  </div>
345
- <div style={{ padding:'12px 14px', background:'var(--green-dim)', borderRadius:10, border:'1px solid rgba(0,232,122,0.2)' }}>
346
- <div className="metric-label">🎯 Hard Cap</div>
347
- <div style={{ fontSize:'2.2rem', fontWeight:900, color:'var(--green)', letterSpacing:'-0.03em' }}>
348
  £{L.hard_cap.toFixed(1)}m
349
  </div>
350
  </div>
@@ -388,11 +495,18 @@ export default function Estimator() {
388
  )}
389
  </div>
390
  </div>
391
- </div>
392
  )}
393
  </div>
394
  </div>
 
 
 
395
  </div>
 
 
 
 
396
  </div>
397
  )
398
  }
 
1
+ import { useState, useRef } from 'react'
2
  import {
3
+ BarChart, Bar, Cell, XAxis, YAxis, Tooltip, LabelList,
4
  ResponsiveContainer, ReferenceLine,
5
  } from 'recharts'
6
+ import { motion } from 'framer-motion'
7
+ import { Download } from 'lucide-react'
8
+ import { useUsageLimiter } from '../hooks/useUsageLimiter'
9
+ import AccessModal from '../components/AccessModal'
10
+ import { ReportTemplate } from '../components/ReportTemplate'
11
+ import DefinitionOfTerms from '../components/DefinitionOfTerms'
12
 
13
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
14
 
 
56
  {/* Track */}
57
  <path d={arc(-180,0)} fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth={18} strokeLinecap="round"/>
58
  {/* Green zone: 0 → cap */}
59
+ <path d={arc(-180, capDeg)} fill="none" stroke="var(--profit-color)" strokeWidth={18} strokeLinecap="round" opacity={0.85}/>
60
  {/* Red zone: cap → asking (only if over) */}
61
  {isOver && (
62
+ <path d={arc(capDeg, Math.min(askDeg, 0))} fill="none" stroke="var(--loss-color)" strokeWidth={18} strokeLinecap="round" opacity={0.85}/>
63
  )}
64
  {/* Needle */}
65
  <line x1={cx} y1={cy} x2={pt(askDeg).x} y2={pt(askDeg).y} stroke="white" strokeWidth={2.5} strokeLinecap="round"/>
66
  <circle cx={cx} cy={cy} r={5} fill="white"/>
67
+ {/* Fair Value label */}
68
  <text x={pt(capDeg).x + (capDeg < -90 ? -6 : 6)} y={pt(capDeg).y - 6}
69
  textAnchor={capDeg < -90 ? 'end' : 'start'}
70
+ fill="var(--profit-color)" fontSize={9} fontWeight={600}>
71
+ Fair Value £{hardCap.toFixed(0)}m
72
  </text>
73
  {/* Center: asking */}
74
  <text x={cx} y={cy + 22} textAnchor="middle" fill="white" fontSize={22} fontWeight={800}>
75
  £{asking.toFixed(0)}m
76
  </text>
77
  <text x={cx} y={cy + 40} textAnchor="middle"
78
+ fill={isOver ? 'var(--loss-color)' : 'var(--profit-color)'} fontSize={12} fontWeight={600}>
79
  {isOver
80
+ ? `▲ £${(asking - hardCap).toFixed(1)}m OVER FAIR VALUE`
81
+ : `✓ Within Fair Value`}
82
  </text>
83
  {/* Scale ticks */}
84
  {[0, 0.25, 0.5, 0.75, 1].map(f => {
 
100
  <YAxis type="category" dataKey="feature" tick={{ fill:'var(--text-2)', fontSize:11 }} width={140} axisLine={false} tickLine={false}/>
101
  <Tooltip
102
  cursor={{ fill:'rgba(255,255,255,0.04)' }}
103
+ contentStyle={{ background:'var(--bg-elevated)', border:'1px solid var(--glass-border-hi)', borderRadius:8, fontSize:12, color:'var(--text-1)' }}
104
+ itemStyle={{ color: 'var(--text-1)' }}
105
+ labelStyle={{ color: 'var(--text-2)', marginBottom: 4 }}
106
+ formatter={(v: number) => [v > 0 ? `+${v.toFixed(3)}` : v.toFixed(3), 'Market Impact']}
107
  />
108
  <ReferenceLine x={0} stroke="rgba(255,255,255,0.12)" strokeWidth={1}/>
109
  <Bar dataKey="impact" radius={3}>
110
+ <LabelList
111
+ dataKey="impact"
112
+ position="right"
113
+ fill="var(--text-1)"
114
+ fontSize={11}
115
+ fontWeight={600}
116
+ formatter={(v: number) => v > 0 ? `+${v.toFixed(2)}` : v.toFixed(2)}
117
+ />
118
  {data.map((entry, i) => (
119
+ <Cell key={i} fill={entry.impact >= 0 ? 'var(--profit-color)' : 'var(--loss-color)'} fillOpacity={0.85}/>
120
  ))}
121
  </Bar>
122
  </BarChart>
 
127
  // ── NLP Sentiment Score ───────────────────────────────────────────────────────
128
  function SentimentScore({ label, value, icon }: { label: string; value: number; icon: string }) {
129
  const pct = Math.round(((value + 1) / 2) * 100)
130
+ const color = value > 0.1 ? 'var(--profit-color)' : value < -0.1 ? 'var(--loss-color)' : 'var(--accent-blue)'
131
  return (
132
  <div>
133
  <div style={{ display:'flex', justifyContent:'space-between', marginBottom:6 }}>
 
143
  )
144
  }
145
 
146
+ // ── Custom Loaders ────────────────────────────────────────────────────────────
147
+ function AILoader() {
148
+ return (
149
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20, margin: '40px 0' }}>
150
+ <motion.div
151
+ animate={{
152
+ rotate: 360,
153
+ scale: [1, 1.2, 1],
154
+ y: [0, -40, 0]
155
+ }}
156
+ transition={{
157
+ rotate: { duration: 1.5, repeat: Infinity, ease: "linear" },
158
+ y: { duration: 0.5, repeat: Infinity, ease: "easeOut", repeatType: "mirror" },
159
+ scale: { duration: 0.5, repeat: Infinity, ease: "easeInOut" }
160
+ }}
161
+ style={{ fontSize: '64px', lineHeight: 1, filter: 'drop-shadow(0 20px 15px rgba(0,0,0,0.6))' }}
162
+ >
163
+
164
+ </motion.div>
165
+ <div style={{ textAlign: 'center' }}>
166
+ <motion.div
167
+ animate={{ opacity: [0.4, 1, 0.4] }}
168
+ transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
169
+ style={{ fontSize: '0.9rem', color: '#60a5fa', fontWeight: 800, letterSpacing: '0.2em' }}
170
+ >
171
+ SCOUTING DATA CHANNELS...
172
+ </motion.div>
173
+ <div style={{ fontSize: '0.7rem', color: 'var(--text-3)', marginTop: 4 }}>
174
+ Accessing Premier League Performance Archives
175
+ </div>
176
+ </div>
177
+ </div>
178
+ )
179
+ }
180
+
181
+ function ButtonPulse() {
182
+ return (
183
+ <motion.div
184
+ animate={{ rotate: 360 }}
185
+ transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
186
+ style={{
187
+ fontSize: '14px',
188
+ lineHeight: 1,
189
+ marginRight: 6,
190
+ display: 'inline-block'
191
+ }}
192
+ >
193
+
194
+ </motion.div>
195
+ )
196
+ }
197
+
198
  // ── Main Estimator Page ───────────────────────────────────────────────────────
199
  export default function Estimator() {
200
+ const { isLocked, incrementUsage } = useUsageLimiter()
201
+ const reportRef = useRef<HTMLDivElement>(null)
202
  const [form, setForm] = useState({
203
  selected_name: '',
204
  current_club: '',
 
216
 
217
  const set = (k: string, v: string | number) => setForm(f => ({ ...f, [k]: v }))
218
 
219
+ const handleSubmit = async (e?: React.FormEvent) => {
220
+ if (e) e.preventDefault()
221
+
222
+ // Auto-Format the Player Name so the PDF always looks professional
223
+ let cleanName = form.selected_name.trim()
224
+ const exactMatch = suggestions.find(s => s.toLowerCase() === cleanName.toLowerCase())
225
+ if (exactMatch) {
226
+ cleanName = exactMatch // Snap to exact database spelling
227
+ } else {
228
+ // Apply Title Case for new/unrecognized players (e.g. "john doe" -> "John Doe")
229
+ cleanName = cleanName.replace(/\b\w/g, c => c.toUpperCase())
230
+ }
231
+
232
+ if (cleanName !== form.selected_name) {
233
+ setForm(prev => ({ ...prev, selected_name: cleanName }))
234
+ }
235
+
236
+ if (!cleanName) { setError('Player name is required.'); return }
237
+ if (!incrementUsage()) return;
238
+
239
  setLoading(true); setError(null); setResult(null)
240
  try {
241
  const res = await fetch(`${API_URL}/api/evaluate`, {
 
255
  }
256
  }
257
 
258
+ const handleDownloadPdf = async () => {
259
+ // Rely on native browser print for high-fidelity vectorized PDFs
260
+ window.print()
261
+ }
262
+
263
  const L = result?.ledger
264
 
265
  return (
266
  <div className="page">
267
+ <div className="no-print">
268
+ {isLocked && <AccessModal />}
269
+ <div className="container">
270
 
271
+ {/* Header */}
272
  <div style={{ marginBottom:36 }}>
273
  <span className="badge badge-green" style={{ marginBottom:10, display:'inline-flex' }}>AI Valuation Engine</span>
274
  <h1 style={{ marginBottom:8 }}>Strategic Transfer Estimator</h1>
 
368
  <button className="btn btn-primary" style={{ width:'100%', justifyContent:'center', padding:14 }}
369
  onClick={handleSubmit} disabled={loading}>
370
  {loading
371
+ ? <><ButtonPulse /> Fetching live intel… (15–30s)</>
372
  : '⚡ Calculate Hard Cap'}
373
  </button>
374
 
 
386
 
387
  {loading && (
388
  <div className="glass" style={{ padding:48, textAlign:'center' }}>
389
+ <AILoader />
390
+ <p style={{ color: 'var(--text-1)', fontWeight: 500 }}>Running ML inference + scraping live market signals…</p>
391
  <p style={{ fontSize:'0.8rem', color:'var(--text-3)', marginTop:8 }}>
392
  Live DDGS intel can take 15–30 seconds first time.
393
  </p>
 
395
  )}
396
 
397
  {result && L && (
398
+ <motion.div
399
+ initial={{ opacity: 0, y: 20 }}
400
+ animate={{ opacity: 1, y: 0 }}
401
+ transition={{ duration: 0.5 }}
402
+ className="animate-in"
403
+ style={{ display:'flex', flexDirection:'column', gap:18 }}
404
+ >
405
 
406
  {/* Verdict alert */}
407
+ <div style={{ display: 'flex', gap: '12px', alignItems: 'stretch' }}>
408
+ <div className={`alert ${form.asking_price > L.hard_cap ? 'alert-danger' : 'alert-success'}`} style={{ flex: 1, margin: 0 }}>
409
+ {form.asking_price > L.hard_cap
410
+ ? `⚠️ OVERPAY RISK — Asking price £${form.asking_price}m exceeds our Fair Value ceiling of £${L.hard_cap.toFixed(1)}m by £${(form.asking_price - L.hard_cap).toFixed(1)}m.`
411
+ : `✅ FAIR DEAL — Asking price £${form.asking_price}m is within the £${L.hard_cap.toFixed(1)}m Fair Value. Proceed with confidence.`}
412
+ </div>
413
+ <button onClick={handleDownloadPdf} className="btn btn-secondary" style={{ padding: '0 24px' }} title="Download Professional PDF Report">
414
+ <Download size={20} />
415
+ Download Report
416
+ </button>
417
  </div>
418
 
419
  {/* Gauge + Ledger */}
 
427
  <div style={{ display:'flex', flexDirection:'column', gap:16 }}>
428
  <div>
429
  <div className="metric-label">Intrinsic Performance Value</div>
430
+ <div className="metric-value" style={{ color:'var(--profit-color)' }}>£{L.intrinsic_performance_value.toFixed(1)}m</div>
431
  <span className="badge badge-green" style={{ marginTop:4 }}>{L.category}</span>
432
  </div>
433
  <div className="divider" style={{ margin:'8px 0' }}/>
434
  <div>
435
+ <div className="metric-label">Age & Contract Impact — {L.depreciation < 0 ? 'Penalty' : 'Premium'}</div>
436
+ <div className="metric-value" style={{ color: L.depreciation < 0 ? 'var(--loss-color)' : 'var(--profit-color)' }}>
437
  {L.depreciation >= 0 ? '+' : ''}£{L.depreciation.toFixed(1)}m
438
  </div>
439
+ <span className="badge badge-blue" style={{ marginTop:4 }}>SHAP Calculated</span>
440
  </div>
441
  <div className="divider" style={{ margin:'8px 0' }}/>
442
  <div>
 
445
  </div>
446
  <div>
447
  <div className="metric-label">NLP Multiplier</div>
448
+ <div className="metric-value" style={{ color: L.external_multiplier > 1 ? 'var(--profit-color)' : 'var(--loss-color)' }}>
449
  ×{L.external_multiplier.toFixed(3)}
450
  </div>
451
  </div>
452
+ <div style={{ padding:'12px 14px', background:'rgba(34,197,94,0.15)', borderRadius:10, border:'1px solid rgba(0,232,122,0.2)' }}>
453
+ <div className="metric-label">🎯 Fair Value Ceiling</div>
454
+ <div style={{ fontSize:'2.2rem', fontWeight:900, color:'var(--profit-color)', letterSpacing:'-0.03em' }}>
455
  £{L.hard_cap.toFixed(1)}m
456
  </div>
457
  </div>
 
495
  )}
496
  </div>
497
  </div>
498
+ </motion.div>
499
  )}
500
  </div>
501
  </div>
502
+
503
+ {/* Definition of Terms Section */}
504
+ <DefinitionOfTerms />
505
  </div>
506
+ </div>
507
+
508
+ {/* Hidden Report Template for PDF Generation */}
509
+ {result && <ReportTemplate ref={reportRef} form={form} result={result} />}
510
  </div>
511
  )
512
  }
fairvalue-webapp/src/pages/FFPAdvisor.tsx CHANGED
@@ -2,7 +2,11 @@ import { useState } from 'react'
2
  import {
3
  PieChart, Pie, Cell, Legend, Tooltip,
4
  BarChart, Bar, XAxis, YAxis, ResponsiveContainer,
 
5
  } from 'recharts'
 
 
 
6
 
7
  // ── PSR Maths (all pure frontend — no API call) ────────────────────────────────
8
  function calcPSR(feeMm: number, contractYrs: number, weeklyWageK: number, agentFeeMm: number) {
@@ -17,6 +21,7 @@ function calcPSR(feeMm: number, contractYrs: number, weeklyWageK: number, agentF
17
  const DONUT_COLORS = ['#505070', '#4f8ef7', '#00e87a']
18
 
19
  export default function FFPAdvisor() {
 
20
  const [feeMm, setFeeMm] = useState(50)
21
  const [contractYrs, setContractYrs] = useState(5)
22
  const [weeklyWageK, setWeeklyWageK] = useState(120)
@@ -46,9 +51,20 @@ export default function FFPAdvisor() {
46
  'Agent Fees': +annualAgent.toFixed(2),
47
  }))
48
 
 
 
 
 
 
49
  return (
50
- <div className="page">
51
- <div className="container">
 
 
 
 
 
 
52
 
53
  {/* Header */}
54
  <div style={{ marginBottom:36 }}>
@@ -154,34 +170,50 @@ export default function FFPAdvisor() {
154
  </div>
155
 
156
  {/* Charts row */}
157
- <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:18 }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
- <div className="glass" style={{ padding:24 }}>
160
- <h3 style={{ marginBottom:12 }}>PSR Budget Overview</h3>
161
- <PieChart width={280} height={230}>
162
- <Pie data={donutData} cx="50%" cy="50%" innerRadius={55} outerRadius={80} dataKey="value" paddingAngle={2}>
163
- {donutData.map((_, i) => <Cell key={i} fill={DONUT_COLORS[i]}/>)}
164
- </Pie>
165
- <Tooltip
166
- contentStyle={{ background:'var(--bg-elevated)', border:'1px solid var(--glass-border)', borderRadius:8, fontSize:12 }}
167
- formatter={(v: number) => [`£${v.toFixed(1)}m`]}/>
168
- <Legend wrapperStyle={{ fontSize:'0.75rem', color:'var(--text-2)' }}/>
169
- </PieChart>
 
 
 
 
170
  </div>
171
 
 
172
  <div className="glass" style={{ padding:24 }}>
173
- <h3 style={{ marginBottom:12 }}>Annual Accounting Schedule</h3>
174
- <ResponsiveContainer width="100%" height={200}>
175
- <BarChart data={barData} margin={{ top:4, right:8, left:-16, bottom:4 }}>
176
  <XAxis dataKey="name" tick={{ fill:'var(--text-2)', fontSize:10 }} axisLine={false} tickLine={false}/>
177
  <YAxis tick={{ fill:'var(--text-2)', fontSize:10 }} axisLine={false} tickLine={false}/>
178
  <Tooltip
179
  contentStyle={{ background:'var(--bg-elevated)', border:'1px solid var(--glass-border)', borderRadius:8, fontSize:11 }}
180
  formatter={(v: number) => [`£${v.toFixed(1)}m`]}/>
181
- <Bar dataKey="Amortisation" stackId="a" fill="#4f8ef7" radius={[0,0,2,2]}/>
182
- <Bar dataKey="Wages" stackId="a" fill="#00e87a"/>
183
- <Bar dataKey="Agent Fees" stackId="a" fill="#f5a623" radius={[2,2,0,0]}/>
184
- </BarChart>
185
  </ResponsiveContainer>
186
  </div>
187
  </div>
@@ -193,7 +225,8 @@ export default function FFPAdvisor() {
193
  </p>
194
  </div>
195
  </div>
196
- </div>
197
  </div>
198
  )
199
  }
 
 
2
  import {
3
  PieChart, Pie, Cell, Legend, Tooltip,
4
  BarChart, Bar, XAxis, YAxis, ResponsiveContainer,
5
+ LineChart, Line
6
  } from 'recharts'
7
+ import { motion } from 'framer-motion'
8
+ import { useUsageLimiter } from '../hooks/useUsageLimiter'
9
+ import AccessModal from '../components/AccessModal'
10
 
11
  // ── PSR Maths (all pure frontend — no API call) ────────────────────────────────
12
  function calcPSR(feeMm: number, contractYrs: number, weeklyWageK: number, agentFeeMm: number) {
 
21
  const DONUT_COLORS = ['#505070', '#4f8ef7', '#00e87a']
22
 
23
  export default function FFPAdvisor() {
24
+ const { isLocked } = useUsageLimiter()
25
  const [feeMm, setFeeMm] = useState(50)
26
  const [contractYrs, setContractYrs] = useState(5)
27
  const [weeklyWageK, setWeeklyWageK] = useState(120)
 
51
  'Agent Fees': +annualAgent.toFixed(2),
52
  }))
53
 
54
+ const bookValueData = years.map((y, i) => ({
55
+ name: y,
56
+ 'Remaining Asset Value': +(feeMm - annualAmort * (i + 1)).toFixed(2)
57
+ }))
58
+
59
  return (
60
+ <div className="page" style={{ position: 'relative' }}>
61
+ {isLocked && <AccessModal />}
62
+ <motion.div
63
+ className="container"
64
+ initial={{ opacity: 0, y: 15 }}
65
+ animate={{ opacity: 1, y: 0 }}
66
+ transition={{ duration: 0.5 }}
67
+ >
68
 
69
  {/* Header */}
70
  <div style={{ marginBottom:36 }}>
 
170
  </div>
171
 
172
  {/* Charts row */}
173
+ <div style={{ display:'flex', flexDirection:'column', gap:18 }}>
174
+ <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:18 }}>
175
+ <div className="glass" style={{ padding:24 }}>
176
+ <h3 style={{ marginBottom:12 }}>PSR Budget Overview</h3>
177
+ <PieChart width={280} height={230}>
178
+ <Pie data={donutData} cx="50%" cy="50%" innerRadius={55} outerRadius={80} dataKey="value" paddingAngle={2}>
179
+ {donutData.map((_, i) => <Cell key={i} fill={DONUT_COLORS[i]}/>)}
180
+ </Pie>
181
+ <Tooltip
182
+ contentStyle={{ background:'var(--bg-elevated)', border:'1px solid var(--glass-border)', borderRadius:8, fontSize:12 }}
183
+ formatter={(v: number) => [`£${v.toFixed(1)}m`]}/>
184
+ <Legend wrapperStyle={{ fontSize:'0.75rem', color:'var(--text-2)' }}/>
185
+ </PieChart>
186
+ </div>
187
 
188
+ <div className="glass" style={{ padding:24 }}>
189
+ <h3 style={{ marginBottom:12 }}>Annual P&L Schedule</h3>
190
+ <ResponsiveContainer width="100%" height={200}>
191
+ <BarChart data={barData} margin={{ top:4, right:8, left:-16, bottom:4 }}>
192
+ <XAxis dataKey="name" tick={{ fill:'var(--text-2)', fontSize:10 }} axisLine={false} tickLine={false}/>
193
+ <YAxis tick={{ fill:'var(--text-2)', fontSize:10 }} axisLine={false} tickLine={false}/>
194
+ <Tooltip
195
+ contentStyle={{ background:'var(--bg-elevated)', border:'1px solid var(--glass-border)', borderRadius:8, fontSize:11 }}
196
+ formatter={(v: number) => [`£${v.toFixed(1)}m`]}/>
197
+ <Bar dataKey="Amortisation" stackId="a" fill="#4f8ef7" radius={[0,0,2,2]}/>
198
+ <Bar dataKey="Wages" stackId="a" fill="#00e87a"/>
199
+ <Bar dataKey="Agent Fees" stackId="a" fill="#f5a623" radius={[2,2,0,0]}/>
200
+ </BarChart>
201
+ </ResponsiveContainer>
202
+ </div>
203
  </div>
204
 
205
+ {/* New Declining Book Value Chart */}
206
  <div className="glass" style={{ padding:24 }}>
207
+ <h3 style={{ marginBottom:12 }}>Remaining Balance Sheet Book Value (Post-Amortisation)</h3>
208
+ <ResponsiveContainer width="100%" height={160}>
209
+ <LineChart data={bookValueData} margin={{ top:4, right:8, left:-16, bottom:4 }}>
210
  <XAxis dataKey="name" tick={{ fill:'var(--text-2)', fontSize:10 }} axisLine={false} tickLine={false}/>
211
  <YAxis tick={{ fill:'var(--text-2)', fontSize:10 }} axisLine={false} tickLine={false}/>
212
  <Tooltip
213
  contentStyle={{ background:'var(--bg-elevated)', border:'1px solid var(--glass-border)', borderRadius:8, fontSize:11 }}
214
  formatter={(v: number) => [`£${v.toFixed(1)}m`]}/>
215
+ <Line type="monotone" dataKey="Remaining Asset Value" stroke="#4f8ef7" strokeWidth={3} dot={{ fill: '#00e87a', strokeWidth: 2, r: 4 }} activeDot={{ r: 6 }}/>
216
+ </LineChart>
 
 
217
  </ResponsiveContainer>
218
  </div>
219
  </div>
 
225
  </p>
226
  </div>
227
  </div>
228
+ </motion.div>
229
  </div>
230
  )
231
  }
232
+
fairvalue-webapp/src/pages/Intel.tsx CHANGED
@@ -1,4 +1,7 @@
1
  import { useState } from 'react'
 
 
 
2
 
3
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
4
 
@@ -16,7 +19,7 @@ function ScoreCard({
16
  }: { label: string; value: number; icon: string; desc: string }) {
17
  const norm = (value + 1) / 2 // –1..+1 → 0..1
18
  const pct = Math.round(norm * 100)
19
- const color = value > 0.1 ? 'var(--green)' : value < -0.1 ? 'var(--red)' : 'var(--blue)'
20
  const label2 = value > 0.15 ? 'Positive' : value < -0.15 ? 'Negative' : 'Neutral'
21
  const badge = value > 0.15 ? 'badge-green' : value < -0.15 ? 'badge-red' : 'badge-blue'
22
 
@@ -68,7 +71,7 @@ function HypeFactorDisplay({ durability, recency, agent }: { durability: number;
68
  return (
69
  <div className="glass" style={{
70
  padding:24,
71
- background: isPos ? 'var(--green-dim)' : 'var(--red-dim)',
72
  borderColor: isPos ? 'rgba(0,232,122,0.2)' : 'rgba(255,77,109,0.2)',
73
  }}>
74
  <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center' }}>
@@ -80,10 +83,10 @@ function HypeFactorDisplay({ durability, recency, agent }: { durability: number;
80
  </p>
81
  </div>
82
  <div style={{ textAlign:'right' }}>
83
- <div style={{ fontSize:'2.8rem', fontWeight:900, color: isPos ? 'var(--green)' : 'var(--red)', letterSpacing:'-0.04em' }}>
84
  ×{mult.toFixed(3)}
85
  </div>
86
- <div style={{ fontSize:'0.82rem', color: isPos ? 'var(--green)' : 'var(--red)', fontWeight:600 }}>
87
  {isPos ? `+${pct}% sentiment premium` : `${pct}% sentiment discount`}
88
  </div>
89
  </div>
@@ -93,6 +96,7 @@ function HypeFactorDisplay({ durability, recency, agent }: { durability: number;
93
  }
94
 
95
  export default function Intel() {
 
96
  const [player, setPlayer] = useState('')
97
  const [club, setClub] = useState('')
98
  const [result, setResult] = useState<ScoutResult | null>(null)
@@ -101,6 +105,7 @@ export default function Intel() {
101
  const [showLog, setShowLog] = useState(false)
102
 
103
  const handleFetch = async () => {
 
104
  if (!player.trim()) { setError('Enter a player name first.'); return }
105
  setLoading(true); setError(null); setResult(null)
106
  try {
@@ -119,8 +124,14 @@ export default function Intel() {
119
  }
120
 
121
  return (
122
- <div className="page">
123
- <div className="container">
 
 
 
 
 
 
124
 
125
  {/* Header */}
126
  <div style={{ marginBottom:36 }}>
@@ -238,7 +249,7 @@ export default function Intel() {
238
  <p>Enter a player name above and click Fetch Intel.</p>
239
  </div>
240
  )}
241
- </div>
242
  </div>
243
  )
244
  }
 
1
  import { useState } from 'react'
2
+ import { motion } from 'framer-motion'
3
+ import { useUsageLimiter } from '../hooks/useUsageLimiter'
4
+ import AccessModal from '../components/AccessModal'
5
 
6
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
7
 
 
19
  }: { label: string; value: number; icon: string; desc: string }) {
20
  const norm = (value + 1) / 2 // –1..+1 → 0..1
21
  const pct = Math.round(norm * 100)
22
+ const color = value > 0.1 ? 'var(--profit-color)' : value < -0.1 ? 'var(--loss-color)' : 'var(--accent-blue)'
23
  const label2 = value > 0.15 ? 'Positive' : value < -0.15 ? 'Negative' : 'Neutral'
24
  const badge = value > 0.15 ? 'badge-green' : value < -0.15 ? 'badge-red' : 'badge-blue'
25
 
 
71
  return (
72
  <div className="glass" style={{
73
  padding:24,
74
+ background: isPos ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
75
  borderColor: isPos ? 'rgba(0,232,122,0.2)' : 'rgba(255,77,109,0.2)',
76
  }}>
77
  <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center' }}>
 
83
  </p>
84
  </div>
85
  <div style={{ textAlign:'right' }}>
86
+ <div style={{ fontSize:'2.8rem', fontWeight:900, color: isPos ? 'var(--profit-color)' : 'var(--loss-color)', letterSpacing:'-0.04em' }}>
87
  ×{mult.toFixed(3)}
88
  </div>
89
+ <div style={{ fontSize:'0.82rem', color: isPos ? 'var(--profit-color)' : 'var(--loss-color)', fontWeight:600 }}>
90
  {isPos ? `+${pct}% sentiment premium` : `${pct}% sentiment discount`}
91
  </div>
92
  </div>
 
96
  }
97
 
98
  export default function Intel() {
99
+ const { isLocked, incrementUsage } = useUsageLimiter()
100
  const [player, setPlayer] = useState('')
101
  const [club, setClub] = useState('')
102
  const [result, setResult] = useState<ScoutResult | null>(null)
 
105
  const [showLog, setShowLog] = useState(false)
106
 
107
  const handleFetch = async () => {
108
+ if (!incrementUsage()) return;
109
  if (!player.trim()) { setError('Enter a player name first.'); return }
110
  setLoading(true); setError(null); setResult(null)
111
  try {
 
124
  }
125
 
126
  return (
127
+ <div className="page" style={{ position: 'relative' }}>
128
+ {isLocked && <AccessModal />}
129
+ <motion.div
130
+ className="container"
131
+ initial={{ opacity: 0, y: 15 }}
132
+ animate={{ opacity: 1, y: 0 }}
133
+ transition={{ duration: 0.5 }}
134
+ >
135
 
136
  {/* Header */}
137
  <div style={{ marginBottom:36 }}>
 
249
  <p>Enter a player name above and click Fetch Intel.</p>
250
  </div>
251
  )}
252
+ </motion.div>
253
  </div>
254
  )
255
  }
fairvalue-webapp/src/pages/Landing.tsx CHANGED
@@ -1,138 +1,207 @@
1
  import { Link } from 'react-router-dom'
 
 
 
2
 
3
- const STATS = [
4
- { value: '12,541', label: 'Transfers Analysed' },
5
- { value: '£638k', label: 'Model MAE' },
6
- { value: '17', label: 'Features Learned' },
7
- { value: '3-Axis', label: 'Live Intelligence' },
8
- ]
9
-
10
- const FEATURES = [
11
- {
12
- icon: '🧠',
13
- title: 'XGBoost + SHAP',
14
- desc: 'Decomposes every valuation into raw talent vs. age & contract depreciation — in pounds, not abstract scores.',
15
- },
16
- {
17
- icon: '📰',
18
- title: 'Live Market Intelligence',
19
- desc: 'Real-time NLP sentiment across injury news, form data, and agent noise adjusts the hard cap before you bid.',
20
- },
21
- {
22
- icon: '⚖️',
23
- title: 'PSR Compliance',
24
- desc: 'Instantly models the full amortisation schedule and flags breaches against the £105m 3-year Premier League limit.',
25
- },
26
- ]
27
 
28
- const STEPS = [
29
- { n: '01', title: 'Profile the Target', desc: 'Enter age, contract status, injury history, and market value estimate.' },
30
- { n: '02', title: 'AI Runs the Numbers', desc: 'XGBoost + SHAP deconstructs talent from depreciation. Live news sentiment adjusts the hard cap.' },
31
- { n: '03', title: 'Receive a Hard Cap', desc: 'A defensible, board-ready maximum bid with full mathematical transparency.' },
 
 
32
  ]
33
 
34
  export default function Landing() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  return (
36
- <div className="page">
37
-
38
- {/* ── Hero ──────────────────────────────────────────────────────────── */}
39
- <section className="container" style={{ textAlign:'center', padding:'60px 24px 48px' }}>
40
- <div className="animate-in">
41
- <span className="badge badge-green" style={{ marginBottom:20, display:'inline-flex' }}>
42
- Powered by XGBoost + SHAP Explainability
43
- </span>
44
- <h1 style={{ marginBottom:20 }}>
45
- The AI That Protects Clubs From<br/>
46
- <span className="gradient-text">Winner's Curse</span>
47
- </h1>
48
- <p style={{ maxWidth:580, margin:'0 auto 36px', fontSize:'1.05rem', lineHeight:1.75 }}>
49
- A rigorous, data-driven transfer ceiling calculator grounded in ML and
50
- Hedonic Pricing Theory built for Directors of Football who can't afford
51
- to overpay.
52
- </p>
53
- <div style={{ display:'flex', gap:12, justifyContent:'center', flexWrap:'wrap' }}>
54
- <Link to="/estimate" className="btn btn-primary btn-lg">
55
- Evaluate a Transfer
56
- </Link>
57
- <Link to="/ffp" className="btn btn-ghost btn-lg">
58
- Check PSR Compliance
59
- </Link>
60
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  </div>
62
  </section>
63
 
64
- {/* ── Stats Bar ─────────────────────────────────────────────────────── */}
65
- <section style={{ borderTop:'1px solid var(--glass-border)', borderBottom:'1px solid var(--glass-border)', padding:'28px 0', marginBottom:64 }}>
66
  <div className="container">
67
- <div style={{ display:'grid', gridTemplateColumns:'repeat(4,1fr)', gap:16, textAlign:'center' }}>
68
- {STATS.map(({ value, label }) => (
69
- <div key={label}>
70
- <div style={{ fontSize:'2rem', fontWeight:800, letterSpacing:'-0.03em', color:'var(--green)' }}>{value}</div>
71
- <div style={{ fontSize:'0.78rem', color:'var(--text-2)', textTransform:'uppercase', letterSpacing:'0.07em', fontWeight:600 }}>{label}</div>
72
- </div>
 
 
 
 
 
 
73
  ))}
74
  </div>
75
  </div>
76
  </section>
77
 
78
- {/* ── Features ──────────────────────────────────────────────────────── */}
79
- <section className="container" style={{ marginBottom:72 }}>
80
- <h2 style={{ textAlign:'center', marginBottom:12 }}>Built for the Transfer Room</h2>
81
- <p style={{ textAlign:'center', marginBottom:40, maxWidth:520, margin:'0 auto 40px' }}>
82
- Every output is explainable, auditable, and defensible to a board.
83
- </p>
84
- <div className="grid-3">
85
- {FEATURES.map(({ icon, title, desc }) => (
86
- <div key={title} className="glass" style={{ padding:28 }}>
87
- <div style={{ fontSize:'2.2rem', marginBottom:14 }}>{icon}</div>
88
- <h3 style={{ marginBottom:10 }}>{title}</h3>
89
- <p style={{ fontSize:'0.88rem', lineHeight:1.7 }}>{desc}</p>
90
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  ))}
92
  </div>
93
  </section>
94
 
95
- {/* ── How It Works ──────────────────────────────────────────────────── */}
96
- <section className="container" style={{ marginBottom:80 }}>
97
- <h2 style={{ textAlign:'center', marginBottom:48 }}>How It Works</h2>
98
- <div style={{ display:'grid', gridTemplateColumns:'repeat(3,1fr)', gap:24, position:'relative' }}>
99
- {/* Connector line */}
100
- <div style={{
101
- position:'absolute', top:28, left:'17%', width:'66%', height:1,
102
- background:'linear-gradient(90deg,var(--green),var(--blue))',
103
- opacity:0.3,
104
- }} />
105
- {STEPS.map(({ n, title, desc }) => (
106
- <div key={n} style={{ textAlign:'center' }}>
107
- <div style={{
108
- width:54, height:54, borderRadius:'50%',
109
- background:'linear-gradient(135deg,var(--green-dim),var(--blue-dim))',
110
- border:'1px solid var(--glass-border)',
111
- display:'flex', alignItems:'center', justifyContent:'center',
112
- margin:'0 auto 18px',
113
- fontWeight:800, fontSize:'1rem', color:'var(--green)',
114
- }}>{n}</div>
115
- <h3 style={{ marginBottom:8 }}>{title}</h3>
116
- <p style={{ fontSize:'0.85rem' }}>{desc}</p>
117
- </div>
118
- ))}
119
  </div>
120
  </section>
121
 
122
- {/* ── CTA ───────────────────────────────────────────────────────────── */}
123
- <section className="container">
124
- <div className="glass" style={{
125
- padding:'48px 40px', textAlign:'center',
126
- background:'linear-gradient(135deg, rgba(0,232,122,0.05), rgba(79,142,247,0.05))',
127
- borderColor:'rgba(0,232,122,0.15)',
128
- }}>
129
- <h2 style={{ marginBottom:12 }}>Ready to protect the budget?</h2>
130
- <p style={{ marginBottom:28 }}>Run your first player evaluation in under 60 seconds.</p>
131
- <Link to="/estimate" className="btn btn-primary btn-lg" style={{ animation:'pulse-ring 2s infinite' }}>
132
- Start Evaluating →
 
 
 
 
 
 
133
  </Link>
134
- </div>
135
  </section>
 
136
  </div>
137
  )
138
  }
 
1
  import { Link } from 'react-router-dom'
2
+ import { motion } from 'framer-motion'
3
+ import { useState, useEffect } from 'react'
4
+ import { ShieldCheck, ArrowRight, TrendingUp, Target, Crosshair, Users, Landmark, Briefcase, UserCheck, PieChart } from 'lucide-react'
5
 
6
+ const PitchBackground = () => (
7
+ <div style={{ position: 'absolute', inset: 0, zIndex: 0, pointerEvents: 'none' }}>
8
+ <div style={{
9
+ position: 'absolute', inset: 0,
10
+ background: 'radial-gradient(circle at 30% 30%, rgba(34, 197, 94, 0.08) 0%, transparent 50%), radial-gradient(circle at 70% 70%, rgba(59, 130, 246, 0.08) 0%, transparent 50%)',
11
+ }} />
12
+ <svg width="100%" height="100%" style={{ opacity: 0.1 }} preserveAspectRatio="xMidYMid slice" viewBox="0 0 1000 600">
13
+ <rect width="1000" height="600" fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth="1" />
14
+ <line x1="500" y1="0" x2="500" y2="600" stroke="rgba(255,255,255,0.2)" strokeWidth="1" />
15
+ <circle cx="500" cy="300" r="90" fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth="1" />
16
+ </svg>
17
+ </div>
18
+ )
 
 
 
 
 
 
 
 
 
 
 
19
 
20
+ const AUDIENCES = [
21
+ { icon: <Briefcase size={24} />, title: "Directors of Football", desc: "Validate transfer ceilings and protect club liquidity." },
22
+ { icon: <Target size={24} />, title: "Head Scouts", desc: "Back the 'eye test' with unshakeable performance data." },
23
+ { icon: <Landmark size={24} />, title: "Financial Officers", desc: "Model 5-year FFP impact before signing the contract." },
24
+ { icon: <Users size={24} />, title: "Elite Agents", desc: "Benchmark client value against live market benchmarks." },
25
+ { icon: <PieChart size={24} />, title: "Investment Groups", desc: "De-risk acquisitions with high-fidelity ROI projections." },
26
  ]
27
 
28
  export default function Landing() {
29
+ const [evalCount, setEvalCount] = useState(84);
30
+
31
+ useEffect(() => {
32
+ const stored = localStorage.getItem('fv_usage_count');
33
+ const localUses = stored ? parseInt(stored, 10) : 0;
34
+ setEvalCount(84 + localUses);
35
+ }, []);
36
+
37
+ const STATS = [
38
+ { value: evalCount.toString(), label: 'Transfers Evaluated' },
39
+ { value: '£14.2m', label: 'Avg. Overpay Avoided' },
40
+ { value: 'PL-Sync', label: 'Market Intelligence' },
41
+ { value: '100%', label: 'PSR Compliance' },
42
+ ]
43
+
44
  return (
45
+ <div className="page" style={{ position: 'relative', overflow: 'hidden' }}>
46
+ <PitchBackground />
47
+
48
+ {/* ── Cinematic Hero ���───────────────────────────────────────────────── */}
49
+ <section className="container" style={{ position: 'relative', zIndex: 1, padding: '40px 24px 60px' }}>
50
+ <div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '48px', alignItems: 'center' }}>
51
+
52
+ <motion.div
53
+ initial={{ opacity: 0, x: -40 }}
54
+ animate={{ opacity: 1, x: 0 }}
55
+ transition={{ duration: 0.8, ease: "easeOut" }}
56
+ >
57
+ <div style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', marginBottom: '24px', padding: '6px 16px', background: 'rgba(59,130,246,0.1)', border: '1px solid rgba(59,130,246,0.2)', borderRadius: '100px' }}>
58
+ <span style={{ fontSize: '1.2rem' }}>🏆</span>
59
+ <span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#60a5fa', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
60
+ The Gold Standard in Transfer Intelligence
61
+ </span>
62
+ </div>
63
+
64
+ <h1 style={{ marginBottom: '24px', fontSize: 'clamp(2.5rem, 6vw, 4.8rem)', lineHeight: 1.05, fontWeight: 900, letterSpacing: '-0.02em' }}>
65
+ Own the Negotiation. <br/>
66
+ <span className="gradient-accent">Master the Market.</span>
67
+ </h1>
68
+
69
+ <p style={{ maxWidth: '600px', marginBottom: '40px', fontSize: '1.25rem', lineHeight: 1.6, color: 'var(--text-2)', fontWeight: 400 }}>
70
+ Stop guessing. Start quantifying. The definitive platform for quantifying the true intrinsic value of world-class talent and eliminating overpayment risk.
71
+ </p>
72
+
73
+ <div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
74
+ <Link to="/estimate" className="btn btn-secondary btn-lg" style={{ padding: '18px 36px' }}>
75
+ Launch Intelligence Engine <ArrowRight size={20} />
76
+ </Link>
77
+ </div>
78
+ </motion.div>
79
+
80
+ <motion.div
81
+ initial={{ opacity: 0, scale: 0.9, rotate: 2 }}
82
+ animate={{ opacity: 1, scale: 1, rotate: 0 }}
83
+ transition={{ duration: 1, delay: 0.2 }}
84
+ style={{ position: 'relative' }}
85
+ >
86
+ <div style={{
87
+ position: 'absolute', inset: -20,
88
+ background: 'radial-gradient(circle, rgba(59,130,246,0.2) 0%, transparent 70%)',
89
+ filter: 'blur(40px)', zIndex: -1
90
+ }} />
91
+ <img
92
+ src="/pl_lion_valuation_hero_1777652300981.png"
93
+ alt="Premier League Intelligence"
94
+ style={{
95
+ width: '100%', borderRadius: '24px',
96
+ border: '1px solid var(--glass-border-hi)',
97
+ boxShadow: '0 40px 100px rgba(0,0,0,0.6)',
98
+ transform: 'perspective(1000px) rotateY(-5deg)'
99
+ }}
100
+ />
101
+ </motion.div>
102
  </div>
103
  </section>
104
 
105
+ {/* ── Financial Stats ───────────────────────────────────────────────── */}
106
+ <section style={{ position: 'relative', zIndex: 1, borderTop: '1px solid var(--glass-border)', borderBottom: '1px solid var(--glass-border)', padding: '40px 0', background: 'rgba(255,255,255,0.02)' }}>
107
  <div className="container">
108
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: '24px', textAlign: 'center' }}>
109
+ {STATS.map(({ value, label }, i) => (
110
+ <motion.div
111
+ key={label}
112
+ initial={{ opacity: 0, y: 20 }}
113
+ whileInView={{ opacity: 1, y: 0 }}
114
+ transition={{ delay: 0.1 * i }}
115
+ viewport={{ once: true }}
116
+ >
117
+ <div style={{ fontSize: '2.8rem', fontWeight: 900, color: 'var(--text-1)', fontFamily: 'Outfit' }}>{value}</div>
118
+ <div style={{ fontSize: '0.8rem', color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>{label}</div>
119
+ </motion.div>
120
  ))}
121
  </div>
122
  </div>
123
  </section>
124
 
125
+ {/* ── Target Audience ───────────────────────────────────────────────── */}
126
+ <section className="container" style={{ position: 'relative', zIndex: 1, padding: '100px 24px' }}>
127
+ <div style={{ textAlign: 'center', marginBottom: '64px' }}>
128
+ <h2 style={{ marginBottom: '16px', fontSize: '2.5rem' }}>Built for the Decision Makers</h2>
129
+ <p style={{ maxWidth: '600px', margin: '0 auto', fontSize: '1.1rem', color: 'var(--text-2)' }}>
130
+ Empowering the most critical roles in the multi-billion pound football industry.
131
+ </p>
132
+ </div>
133
+
134
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '20px' }}>
135
+ {AUDIENCES.map((item, i) => (
136
+ <motion.div
137
+ key={item.title}
138
+ whileHover={{ y: -10, background: 'rgba(255,255,255,0.06)' }}
139
+ initial={{ opacity: 0, y: 30 }}
140
+ whileInView={{ opacity: 1, y: 0 }}
141
+ transition={{ delay: 0.1 * i }}
142
+ viewport={{ once: true }}
143
+ className="glass"
144
+ style={{ padding: '28px', textAlign: 'center', border: '1px solid var(--glass-border)' }}
145
+ >
146
+ <div style={{
147
+ width: 50, height: 50, borderRadius: '12px',
148
+ background: 'rgba(59,130,246,0.1)', display: 'flex',
149
+ alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px',
150
+ color: '#60a5fa'
151
+ }}>
152
+ {item.icon}
153
+ </div>
154
+ <h4 style={{ marginBottom: '12px', fontSize: '1.05rem', color: 'var(--text-1)' }}>{item.title}</h4>
155
+ <p style={{ fontSize: '0.82rem', color: 'var(--text-3)', lineHeight: 1.5 }}>{item.desc}</p>
156
+ </motion.div>
157
  ))}
158
  </div>
159
  </section>
160
 
161
+ {/* ── Value Proposition Cards ───────────────────────────────────────── */}
162
+ <section className="container" style={{ position: 'relative', zIndex: 1, marginBottom: '100px' }}>
163
+ <div className="grid-3" style={{ gap: '24px' }}>
164
+ <motion.div whileHover={{ scale: 1.02 }} className="glass" style={{ padding: '32px' }}>
165
+ <Target size={32} color="#3b82f6" style={{ marginBottom: '24px' }} />
166
+ <h3 style={{ marginBottom: '12px' }}>Intrinsic Valuation</h3>
167
+ <p style={{ fontSize: '0.95rem' }}>Isolate pure performance metrics from market noise to identify the true ceiling price of any asset.</p>
168
+ </motion.div>
169
+
170
+ <motion.div whileHover={{ scale: 1.02 }} className="glass" style={{ padding: '32px' }}>
171
+ <Crosshair size={32} color="#f5a623" style={{ marginBottom: '24px' }} />
172
+ <h3 style={{ marginBottom: '12px' }}>Live Market Pulse</h3>
173
+ <p style={{ fontSize: '0.95rem' }}>Real-time newsroom scraping for durability, form, and agent leverage to adjust valuations on the fly.</p>
174
+ </motion.div>
175
+
176
+ <motion.div whileHover={{ scale: 1.02 }} className="glass" style={{ padding: '32px' }}>
177
+ <ShieldCheck size={32} color="#22c55e" style={{ marginBottom: '24px' }} />
178
+ <h3 style={{ marginBottom: '12px' }}>Board-Ready Compliance</h3>
179
+ <p style={{ fontSize: '0.95rem' }}>Instantly calculate amortisation hits and ensure every bid stays within PSR & FFP safety zones.</p>
180
+ </motion.div>
 
 
 
 
181
  </div>
182
  </section>
183
 
184
+ {/* ── Enterprise CTA ────────────────────────────────────────────────── */}
185
+ <section className="container" style={{ position: 'relative', zIndex: 1, marginBottom: '80px' }}>
186
+ <motion.div
187
+ whileInView={{ opacity: 1, y: 0 }}
188
+ initial={{ opacity: 0, y: 40 }}
189
+ viewport={{ once: true }}
190
+ className="glass"
191
+ style={{
192
+ padding: '60px 48px', textAlign: 'center',
193
+ background: 'linear-gradient(135deg, rgba(34,197,94,0.08), rgba(59,130,246,0.1))',
194
+ borderColor: 'var(--glass-border-hi)',
195
+ }}
196
+ >
197
+ <h2 style={{ marginBottom: '16px', fontSize: '2.5rem' }}>Maximize your net spend.</h2>
198
+ <p style={{ marginBottom: '40px', color: 'var(--text-2)', fontSize: '1.1rem' }}>Join the elite recruitment departments using data to win the transfer window.</p>
199
+ <Link to="/estimate" className="btn btn-secondary btn-lg">
200
+ Start Free Evaluation <TrendingUp size={22} style={{ marginLeft: 10 }} />
201
  </Link>
202
+ </motion.div>
203
  </section>
204
+
205
  </div>
206
  )
207
  }
recruitment_leads.txt ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FAIRVALUE: HIGH-PRIORITY RECRUITMENT LEAD LIST
2
+ ==========================================
3
+
4
+ TARGET PERSONAS:
5
+ 1. Directors of Football (DOFs)
6
+ 2. Sporting Directors
7
+ 3. Heads of Recruitment / Chief Scouts
8
+ 4. CFOs / PSR Compliance Managers
9
+
10
+ THE "BIG 6" HIT LIST (2025/26 Season):
11
+ --------------------------------------
12
+ CLUB: Arsenal
13
+ NAME: Richard Garlick
14
+ ROLE: Director of Football Operations
15
+ EMAIL PATTERN: richard.garlick@arsenal.com
16
+
17
+ CLUB: Manchester City
18
+ NAME: Txiki Begiristain
19
+ ROLE: Director of Football
20
+ EMAIL PATTERN: txikibegiristain@mancity.com
21
+
22
+ CLUB: Manchester United
23
+ NAME: Dan Ashworth
24
+ ROLE: Sporting Director
25
+ EMAIL PATTERN: dan.ashworth@mufc.co.uk
26
+
27
+ CLUB: Liverpool
28
+ NAME: Richard Hughes
29
+ ROLE: Sporting Director
30
+ EMAIL PATTERN: richard.hughes@liverpoolfc.com
31
+
32
+ CLUB: Chelsea
33
+ NAME: Paul Winstanley / Laurence Stewart
34
+ ROLE: Co-Sporting Directors
35
+ EMAIL PATTERN: paul.winstanley@chelseafc.com
36
+
37
+ CLUB: Aston Villa
38
+ NAME: Monchi (Ramón Rodríguez Verdejo)
39
+ ROLE: President of Football Operations
40
+ EMAIL PATTERN: monchi@avfc.co.uk
41
+
42
+ CLUB: Tottenham Hotspur
43
+ NAME: Johan Lange
44
+ ROLE: Technical Director
45
+ EMAIL PATTERN: johan.lange@tottenhamhotspur.com
46
+
47
+
48
+ VALIDATED CLUB EMAIL PATTERNS:
49
+ ------------------------------
50
+ - [first].[last]@arsenal.com
51
+ - [first].[last]@chelseafc.com
52
+ - [first].[last]@liverpoolfc.com
53
+ - [first][last]@mancity.com
54
+ - [first].[last]@mufc.co.uk
55
+ - [first].[last]@tottenhamhotspur.com
56
+ - [first].[last]@avfc.co.uk
57
+ - [first].[last]@nufc.co.uk (Newcastle)
58
+
59
+
60
+ THE "SNIPER" OUTREACH METHOD:
61
+ -----------------------------
62
+ 1. CROSS-DEPARTMENT ATTACK:
63
+ - Send the 'PSR Advisor' email to the CFO/Finance Dept.
64
+ - Send the 'Negotiation Leverage' email to the Sporting Director.
65
+ - Purpose: Force a conversation between Finance and Recruitment where YOUR tool is the solution.
66
+
67
+ 2. LINKEDIN VALIDATION:
68
+ - Search: "[Club Name] Head of Recruitment"
69
+ - Use Hunter.io or RocketReach to verify the email against the patterns above.
70
+
71
+ 3. DEADLINE DAY PRESSURE:
72
+ - Send the 'Rapid Valuation' email 48 hours before the window closes.
73
+ - This is when the "Hype Gap" is most dangerous and decision-makers are most desperate.
74
+
75
+ 4. ONE-OFF BAIT:
76
+ - Always offer to run a "Free Intrinsic Audit" on one specific high-stakes player they are currently linked with in the press.
requirements.txt CHANGED
@@ -1,5 +1,5 @@
1
  streamlit==1.32.0
2
- xgboost==2.0.3
3
  shap==0.44.0
4
  pandas==2.2.0
5
  numpy==1.26.4
 
1
  streamlit==1.32.0
2
+ xgboost>=3.0.0
3
  shap==0.44.0
4
  pandas==2.2.0
5
  numpy==1.26.4
scratch/eval_top_5.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import json
3
+ import time
4
+
5
+ players = [
6
+ {"selected_name": "Erling Haaland", "current_club": "Manchester City", "contract_years": 3, "age": 23, "injuries_24m": 20, "asking_price": 180, "market_value_estimation": 180},
7
+ {"selected_name": "Bukayo Saka", "current_club": "Arsenal", "contract_years": 3, "age": 22, "injuries_24m": 10, "asking_price": 130, "market_value_estimation": 130},
8
+ {"selected_name": "William Saliba", "current_club": "Arsenal", "contract_years": 4, "age": 23, "injuries_24m": 5, "asking_price": 80, "market_value_estimation": 80},
9
+ {"selected_name": "Ollie Watkins", "current_club": "Aston Villa", "contract_years": 4, "age": 28, "injuries_24m": 15, "asking_price": 65, "market_value_estimation": 65},
10
+ {"selected_name": "Martin Odegaard", "current_club": "Arsenal", "contract_years": 4, "age": 25, "injuries_24m": 25, "asking_price": 95, "market_value_estimation": 95}
11
+ ]
12
+
13
+ url = "https://britzzy-fairvalue-api.hf.space/api/evaluate"
14
+
15
+ print("Evaluating 5 Premiership Players...\n")
16
+
17
+ for p in players:
18
+ try:
19
+ print(f"Requesting evaluation for {p['selected_name']}...")
20
+ response = requests.post(url, json=p, timeout=30)
21
+
22
+ if response.status_code == 200:
23
+ res = response.json()
24
+ L = res["ledger"]
25
+ nlp = res["nlp_results"]
26
+
27
+ print(f"--- {p['selected_name']} ({p['current_club']}) ---")
28
+ print(f"Age: {p['age']} | Contract: {p['contract_years']}yr | Market Value: £{p['market_value_estimation']}m")
29
+ print(f"ML Baseline Value: £{L['baseline_value']:.1f}m")
30
+ print(f"Intrinsic Talent: £{L['intrinsic_performance_value']:.1f}m")
31
+ print(f"Depreciation Penalty: £{L['depreciation']:.1f}m")
32
+ print(f"Hard Cap limit: £{L['hard_cap']:.1f}m")
33
+ print(f"NLP Sentiments - Durability: {nlp['durability']:.2f}, Form: {nlp['recency']:.2f}, Agent: {nlp['agent']:.2f}")
34
+ print("-------------------------------------------------\n")
35
+ else:
36
+ print(f"Failed for {p['selected_name']}: HTTP {response.status_code} - {response.text}")
37
+ except Exception as e:
38
+ print(f"Error fetching {p['selected_name']}: {str(e)}")
39
+
40
+ time.sleep(2)
scratch/ui-ux-pro-max-skill ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit b7e3af80f6e331f6fb456667b82b12cade7c9d35
test2.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import xgboost as xgb
3
+
4
+ df = pd.read_csv("data/processed/app_features.csv")
5
+ model = xgb.XGBRegressor()
6
+ model.load_model("fairvalue_xgboost.json")
7
+ expected_cols = model.feature_names_in_
8
+
9
+ print("Expected cols:")
10
+ print(expected_cols[:5])
11
+
12
+ # Find name column
13
+ name_col = next((c for c in ['name', 'name_x', 'Player_Name', 'Name'] if c in df.columns), None)
14
+ print("Name col:", name_col)
15
+ if name_col:
16
+ print("Bruno matches:", df[df[name_col].astype(str).str.contains("bruno fern", case=False, na=False)][name_col].tolist())
17
+
18
+ med = df.median(numeric_only=True).to_frame().T
19
+ missing = [c for c in expected_cols if c not in med.columns]
20
+ print(f"Missing from median: {len(missing)} out of {len(expected_cols)}")
21
+ if len(missing) > 0:
22
+ print("First 5 missing:", missing[:5])
test3.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import xgboost as xgb
3
+ import numpy as np
4
+
5
+ # Load data and model
6
+ df = pd.read_csv("data/processed/app_features.csv")
7
+
8
+ mv_rename_map = {col: 'market_value_in_eur' for col in df.columns if 'market' in col.lower() and 'value' in col.lower()}
9
+ if mv_rename_map:
10
+ df.rename(columns=mv_rename_map, inplace=True)
11
+ df = df.loc[:, ~df.columns.duplicated()].copy()
12
+
13
+ model = xgb.XGBRegressor()
14
+ model.load_model("fairvalue_xgboost.json")
15
+ expected_cols = model.feature_names_in_
16
+
17
+ player_data = df.median(numeric_only=True).to_frame().T
18
+
19
+ # Simulate what api/main.py does
20
+ player_data['Contract_Years_Left'] = 2.5
21
+ player_data['Age'] = 28
22
+ player_data['market_value_in_eur'] = (120 * 1_000_000) / 0.85
23
+
24
+ X_infer = player_data.reindex(columns=expected_cols, fill_value=0)
25
+
26
+ preds = model.predict(X_infer)
27
+ log_pv = preds[0]
28
+ baseline_pv = np.expm1(log_pv)
29
+
30
+ print(f"Log PV: {log_pv}")
31
+ print(f"Baseline PV (Euros): {baseline_pv}")
32
+ print(f"Baseline PV_m (Millions): {baseline_pv / 1_000_000}")
test4.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import xgboost as xgb
3
+ import numpy as np
4
+
5
+ df = pd.read_csv("data/processed/app_features.csv")
6
+ mv_rename_map = {col: 'market_value_in_eur' for col in df.columns if 'market' in col.lower() and 'value' in col.lower()}
7
+ if mv_rename_map:
8
+ df.rename(columns=mv_rename_map, inplace=True)
9
+ df = df.loc[:, ~df.columns.duplicated()].copy()
10
+
11
+ model = xgb.XGBRegressor()
12
+ model.load_model("fairvalue_xgboost.json")
13
+ expected_cols = model.feature_names_in_
14
+
15
+ player_data = df.median(numeric_only=True).to_frame().T
16
+ player_data['Contract_Years_Left'] = 2.5
17
+ player_data['Age'] = 28
18
+ player_data['market_value_in_eur'] = (120 * 1_000_000) / 0.85
19
+
20
+ X_infer = player_data.reindex(columns=expected_cols, fill_value=0)
21
+
22
+ dmatrix = xgb.DMatrix(X_infer)
23
+ shap_contribs = model.get_booster().predict(dmatrix, pred_contribs=True)[0]
24
+
25
+ print("Base value:", shap_contribs[-1])
26
+ for f, s in zip(expected_cols, shap_contribs[:-1]):
27
+ print(f"{f}: {s}")
test_inference.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import xgboost as xgb
3
+ import numpy as np
4
+
5
+ # Load data and model
6
+ df = pd.read_csv("data/processed/app_features.csv")
7
+
8
+ mv_rename_map = {col: 'market_value_in_eur' for col in df.columns if 'market' in col.lower() and 'value' in col.lower()}
9
+ if mv_rename_map:
10
+ df.rename(columns=mv_rename_map, inplace=True)
11
+ df = df.loc[:, ~df.columns.duplicated()].copy()
12
+
13
+ model = xgb.XGBRegressor()
14
+ model.load_model("fairvalue_xgboost.json")
15
+ expected_cols = model.feature_names_in_
16
+
17
+ name_col = next((c for c in ['name', 'name_x', 'Player_Name', 'Name'] if c in df.columns), None)
18
+
19
+ print("Name Col:", name_col)
20
+ print("Is Bruno in df?", "Bruno Fernandes" in df[name_col].astype(str).tolist())
21
+
22
+ player_data = df[df[name_col].astype(str) == "Bruno Fernandes"].iloc[0:1].copy() if name_col else df.median().to_frame().T
23
+
24
+ print("Pre-update market_value_in_eur:", player_data.get('market_value_in_eur', pd.Series([None])).iloc[0])
25
+
26
+ player_data['Contract_Years_Left'] = 2.5
27
+ player_data['Age'] = 28
28
+ player_data['market_value_in_eur'] = (120 * 1_000_000) / 0.85
29
+
30
+ print("Post-update market_value_in_eur:", player_data['market_value_in_eur'].iloc[0])
31
+
32
+ X_infer = player_data.reindex(columns=expected_cols, fill_value=0)
33
+
34
+ print("X_infer expected length:", len(expected_cols))
35
+ print("Any missing cols?", [c for c in expected_cols if c not in player_data.columns])
36
+
37
+ preds = model.predict(X_infer)
38
+ print("Raw pred:", preds[0])
39
+ print("Exp PV:", np.expm1(preds[0]))
40
+
41
+ dmatrix = xgb.DMatrix(X_infer)
42
+ shap_vals = model.get_booster().predict(dmatrix, pred_contribs=True)[0]
43
+ print("SHAP max / min:", shap_vals.max(), shap_vals.min())
44
+