Spaces:
Running
Running
FairValue commited on
Commit ·
7dff677
1
Parent(s): 1f734ca
feat: implement professional PDF negotiation reports and terminology refinement
Browse files- .gitignore +3 -0
- .vercelignore +18 -0
- FairValue High-Conversion Outreach.txt +88 -0
- README.md +1 -4
- api/main.py +46 -0
- fairvalue-webapp/.gitignore +2 -0
- fairvalue-webapp/package-lock.json +272 -0
- fairvalue-webapp/package.json +4 -0
- fairvalue-webapp/src/App.tsx +5 -2
- fairvalue-webapp/src/components/AccessModal.tsx +147 -0
- fairvalue-webapp/src/components/DefinitionOfTerms.tsx +45 -0
- fairvalue-webapp/src/components/Navbar.tsx +4 -3
- fairvalue-webapp/src/components/ReportTemplate.tsx +210 -0
- fairvalue-webapp/src/components/SecretGate.tsx +204 -0
- fairvalue-webapp/src/hooks/useUsageLimiter.ts +36 -0
- fairvalue-webapp/src/index.css +195 -129
- fairvalue-webapp/src/pages/AboutDeveloper.tsx +244 -0
- fairvalue-webapp/src/pages/Estimator.tsx +149 -35
- fairvalue-webapp/src/pages/FFPAdvisor.tsx +55 -22
- fairvalue-webapp/src/pages/Intel.tsx +18 -7
- fairvalue-webapp/src/pages/Landing.tsx +179 -110
- recruitment_leads.txt +76 -0
- requirements.txt +1 -1
- scratch/eval_top_5.py +40 -0
- scratch/ui-ux-pro-max-skill +1 -0
- test2.py +22 -0
- test3.py +32 -0
- test4.py +27 -0
- test_inference.py +44 -0
.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:
|
| 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="/"
|
| 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(--
|
| 46 |
-
background: isActive ? '
|
| 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(--
|
| 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=
|
| 2 |
|
| 3 |
-
/* ──
|
| 4 |
:root {
|
| 5 |
-
--bg-base: #
|
| 6 |
-
--bg-elevated: #
|
| 7 |
-
--bg-elevated-2: #
|
| 8 |
|
| 9 |
-
--glass-bg: rgba(255,255,255,0.
|
| 10 |
--glass-border: rgba(255,255,255,0.08);
|
| 11 |
-
--glass-
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
--
|
| 15 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
--
|
| 18 |
-
--
|
| 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:
|
| 32 |
-
--radius-xl:
|
| 33 |
|
| 34 |
-
--transition: all 0.
|
| 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
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
| 56 |
p { color: var(--text-2); line-height: 1.7; }
|
| 57 |
|
| 58 |
/* ── Layout ──────────────────────────────────────────────────────────────────── */
|
| 59 |
-
.container { max-width:
|
| 60 |
-
.page { min-height: calc(100vh - 68px); padding:
|
| 61 |
|
| 62 |
-
/* ── Glass Card ──────────────────────
|
| 63 |
.glass {
|
| 64 |
-
background:
|
| 65 |
border: 1px solid var(--glass-border);
|
| 66 |
border-radius: var(--radius-lg);
|
| 67 |
-
backdrop-filter: blur(
|
|
|
|
|
|
|
|
|
|
| 68 |
transition: var(--transition);
|
| 69 |
}
|
| 70 |
.glass:hover {
|
| 71 |
-
background:
|
| 72 |
-
border-color:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 84 |
border-radius: var(--radius);
|
| 85 |
-
font-
|
|
|
|
| 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.
|
| 92 |
.btn-primary {
|
| 93 |
-
background: var(--
|
| 94 |
-
box-shadow:
|
| 95 |
}
|
| 96 |
.btn-primary:hover:not(:disabled) {
|
| 97 |
-
background: #
|
| 98 |
-
box-shadow: 0
|
| 99 |
-
transform: translateY(-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
/* ── Form Inputs ─────────────────────────────────────────────────────────────── */
|
| 109 |
-
.input-group { display: flex; flex-direction: column; gap:
|
| 110 |
.field-label {
|
| 111 |
-
font-
|
|
|
|
| 112 |
color: var(--text-2);
|
| 113 |
-
text-transform: uppercase; letter-spacing: 0.
|
| 114 |
}
|
| 115 |
.input, select.input {
|
| 116 |
-
background:
|
| 117 |
border: 1px solid var(--glass-border);
|
| 118 |
border-radius: var(--radius);
|
| 119 |
color: var(--text-1);
|
| 120 |
-
font-size:
|
| 121 |
-
padding:
|
| 122 |
outline: none;
|
| 123 |
transition: var(--transition);
|
| 124 |
width: 100%;
|
| 125 |
}
|
| 126 |
.input:focus, select.input:focus {
|
| 127 |
-
border-color: var(--
|
| 128 |
-
box-shadow: 0 0 0 3px rgba(
|
| 129 |
}
|
| 130 |
select.input option { background: var(--bg-elevated); }
|
| 131 |
-
textarea.input { resize: vertical; min-height:
|
| 132 |
|
| 133 |
-
/* Range slider */
|
| 134 |
input[type="range"] {
|
| 135 |
-webkit-appearance: none; appearance: none;
|
| 136 |
-
width: 100%; height:
|
| 137 |
background: var(--bg-elevated-2); outline: none;
|
| 138 |
-
margin:
|
| 139 |
}
|
| 140 |
input[type="range"]::-webkit-slider-thumb {
|
| 141 |
-webkit-appearance: none;
|
| 142 |
-
width:
|
| 143 |
-
background: var(--
|
| 144 |
-
box-shadow: 0 0
|
|
|
|
| 145 |
}
|
| 146 |
-
|
| 147 |
-
.range-
|
|
|
|
| 148 |
|
| 149 |
-
/* ── Metrics ─────────────────────────────────────────────────────────
|
| 150 |
-
.metric { display: flex; flex-direction: column; gap:
|
| 151 |
.metric-label {
|
| 152 |
-
font-size: 0.
|
| 153 |
-
color: var(--text-2);
|
| 154 |
-
text-transform: uppercase; letter-spacing: 0.
|
| 155 |
}
|
| 156 |
-
.metric-value { font-size:
|
| 157 |
-
.metric-note { font-size: 0.
|
|
|
|
|
|
|
| 158 |
|
| 159 |
/* ── Badges ──────────────────────────────────────────────────────────────────── */
|
| 160 |
.badge {
|
| 161 |
-
display: inline-flex; align-items: center;
|
| 162 |
-
padding:
|
| 163 |
-
font-size: 0.
|
|
|
|
| 164 |
}
|
| 165 |
-
.badge-
|
| 166 |
-
.badge-
|
| 167 |
-
.badge-red { background:
|
| 168 |
-
.badge-
|
| 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:
|
| 199 |
-
.grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap:
|
| 200 |
-
.grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap:
|
| 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 |
-
/* ──
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
| 225 |
background-clip: text;
|
| 226 |
}
|
| 227 |
|
| 228 |
-
/* ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
.sentiment-bar-track {
|
| 230 |
-
height:
|
| 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(--
|
| 54 |
{/* Red zone: cap → asking (only if over) */}
|
| 55 |
{isOver && (
|
| 56 |
-
<path d={arc(capDeg, Math.min(askDeg, 0))} fill="none" stroke="var(--
|
| 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 |
-
{/*
|
| 62 |
<text x={pt(capDeg).x + (capDeg < -90 ? -6 : 6)} y={pt(capDeg).y - 6}
|
| 63 |
textAnchor={capDeg < -90 ? 'end' : 'start'}
|
| 64 |
-
fill="var(--
|
| 65 |
-
|
| 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(--
|
| 73 |
{isOver
|
| 74 |
-
? `▲ £${(asking - hardCap).toFixed(1)}m OVER
|
| 75 |
-
: `✓ Within
|
| 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 |
-
|
|
|
|
|
|
|
| 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(--
|
| 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(--
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
|
|
|
|
|
|
| 175 |
|
| 176 |
-
|
| 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 |
-
? <><
|
| 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 |
-
<
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
{/* Verdict alert */}
|
| 306 |
-
<div
|
| 307 |
-
{form.asking_price > L.hard_cap
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(--
|
| 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
|
| 329 |
-
<div className="metric-value" style={{ color: L.depreciation < 0 ? 'var(--
|
| 330 |
{L.depreciation >= 0 ? '+' : ''}£{L.depreciation.toFixed(1)}m
|
| 331 |
</div>
|
| 332 |
-
<span className="badge badge-blue" style={{ marginTop:4 }}>SHAP Calculated
|
| 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(--
|
| 342 |
×{L.external_multiplier.toFixed(3)}
|
| 343 |
</div>
|
| 344 |
</div>
|
| 345 |
-
<div style={{ padding:'12px 14px', background:'
|
| 346 |
-
<div className="metric-label">🎯
|
| 347 |
-
<div style={{ fontSize:'2.2rem', fontWeight:900, color:'var(--
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
</div>
|
| 171 |
|
|
|
|
| 172 |
<div className="glass" style={{ padding:24 }}>
|
| 173 |
-
<h3 style={{ marginBottom:12 }}>
|
| 174 |
-
<ResponsiveContainer width="100%" height={
|
| 175 |
-
<
|
| 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 |
-
<
|
| 182 |
-
|
| 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(--
|
| 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 ? '
|
| 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(--
|
| 84 |
×{mult.toFixed(3)}
|
| 85 |
</div>
|
| 86 |
-
<div style={{ fontSize:'0.82rem', color: isPos ? 'var(--
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 4 |
-
{
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 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
|
| 29 |
-
{
|
| 30 |
-
{
|
| 31 |
-
{
|
|
|
|
|
|
|
| 32 |
]
|
| 33 |
|
| 34 |
export default function Landing() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
return (
|
| 36 |
-
<div className="page">
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
<
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
</div>
|
| 62 |
</section>
|
| 63 |
|
| 64 |
-
{/* ── Stats
|
| 65 |
-
<section style={{ borderTop:'1px solid var(--glass-border)', borderBottom:'1px solid var(--glass-border)', padding:'
|
| 66 |
<div className="container">
|
| 67 |
-
<div style={{ display:'grid', gridTemplateColumns:'repeat(4,1fr)', gap:
|
| 68 |
-
{STATS.map(({ value, label }) => (
|
| 69 |
-
<div
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
))}
|
| 74 |
</div>
|
| 75 |
</div>
|
| 76 |
</section>
|
| 77 |
|
| 78 |
-
{/* ──
|
| 79 |
-
<section className="container" style={{
|
| 80 |
-
<
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
))}
|
| 92 |
</div>
|
| 93 |
</section>
|
| 94 |
|
| 95 |
-
{/* ──
|
| 96 |
-
<section className="container" style={{ marginBottom:
|
| 97 |
-
<
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
}}
|
| 105 |
-
|
| 106 |
-
<
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 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
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
+
|