feat: production codebase for Statista (https://statista.steelforesight.ir)
This commit is contained in:
commit
a188b48679
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Skill(run)",
|
||||
"Skill(run:*)",
|
||||
"PowerShell(python -c \"import importlib.util as u; [print\\(m, 'OK' if u.find_spec\\(m\\) else 'MISSING'\\) for m in ['fastapi','uvicorn','yfinance','pandas','websockets']]\")",
|
||||
"PowerShell($env:PYTHONUNBUFFERED=\"1\"; python -m uvicorn main:app --host 127.0.0.1 --port 8000 --app-dir backend)",
|
||||
"PowerShell(npm run dev)",
|
||||
"PowerShell(python -c \"import importlib.util as u; print\\('playwright', 'OK' if u.find_spec\\('playwright'\\) else 'MISSING'\\)\")",
|
||||
"PowerShell($env:PYTHONUNBUFFERED=\"1\"; $env:PYTHONUTF8=\"1\"; python -m uvicorn main:app --host 127.0.0.1 --port 8000 --app-dir backend)",
|
||||
"Read(//c/Users/DATIS STAR/.claude/skills/impeccable/**)",
|
||||
"Read(//c/Users/DATIS STAR/.agents/skills/impeccable/**)",
|
||||
"PowerShell($env:PYTHONUNBUFFERED=\"1\"; $env:PYTHONUTF8=\"1\"; python -m uvicorn admin_app:app --host 127.0.0.1 --port 8001 --app-dir admin)",
|
||||
"PowerShell(npx --yes shadcn@latest add \"https://reactbits.dev/r/LaserFlow-JS-CSS.json\" --yes 2>&1)",
|
||||
"PowerShell(npm install three@^0.167.1 @types/three --silent 2>&1)",
|
||||
"PowerShell(npm install framer-motion --silent 2>&1)",
|
||||
"PowerShell(npm install gsap --silent 2>&1)",
|
||||
"PowerShell(npm install ogl --silent 2>&1)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5174/)",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:5174/)",
|
||||
"Bash(echo \"home: $\\(curl -s -o /dev/null -w '%{http_code}' http://localhost:5174/\\)\")",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:5174/dashboard)",
|
||||
"Bash(echo \"dashboard: $\\(curl -s -o /dev/null -w '%{http_code}' http://localhost:5174/dashboard\\)\")",
|
||||
"Bash(curl -s -m 8 \"http://localhost:8000/api/reports\" -o NUL -w \"reports endpoint: %{http_code}\\\\n\")",
|
||||
"Bash(curl -s -m 10 'http://localhost:8000/api/reports' -o NUL -w '%{http_code}')",
|
||||
"Bash(echo \"summary: $\\(curl -s -m 10 'http://localhost:8000/api/reports' -o NUL -w '%{http_code}'\\)\")",
|
||||
"Bash(curl -s -m 10 'http://localhost:8000/api/reports/files?folder=Platts%20-%20Metals%20Daily' -o NUL -w '%{http_code}')",
|
||||
"Bash(echo \"files: $\\(curl -s -m 10 'http://localhost:8000/api/reports/files?folder=Platts%20-%20Metals%20Daily' -o NUL -w '%{http_code}'\\)\")",
|
||||
"Bash(curl -s -m 10 \"http://localhost:8000/api/reports\")",
|
||||
"Bash(curl -s -m 15 -o NUL -w \"status:%{http_code} type:%{content_type} bytes:%{size_download}\\\\n\" \"http://localhost:8000/api/reports/file?folder=Platts%20-%20Metals%20Daily&name=Metals%20Daily%20\\(27022026\\).pdf\")",
|
||||
"WebFetch(domain:api.coingecko.com)",
|
||||
"WebFetch(domain:www.coingecko.com)",
|
||||
"PowerShell(try { \"dashboard: \" + \\(Invoke-WebRequest -Uri 'http://localhost:5174/dashboard' -UseBasicParsing -TimeoutSec 12\\).StatusCode } catch { \"ERR: $\\($_.Exception.Message\\)\" })",
|
||||
"PowerShell(npm install -g ngrok 2>&1)",
|
||||
"PowerShell(ngrok http 5174 --log stdout)",
|
||||
"PowerShell(Stop-Process -Id 149232 -Force -ErrorAction SilentlyContinue)",
|
||||
"PowerShell(Get-Process ngrok -ErrorAction SilentlyContinue)",
|
||||
"PowerShell(Stop-Process -Force -ErrorAction SilentlyContinue)",
|
||||
"PowerShell(\"killed old ngrok\")",
|
||||
"PowerShell(& \"C:\\\\Users\\\\DATIS STAR\\\\AppData\\\\Roaming\\\\npm\\\\node_modules\\\\ngrok\\\\bin\\\\ngrok.exe\" http 5174 --log stdout)",
|
||||
"PowerShell(try { \"login: \" + \\(Invoke-WebRequest 'http://localhost:5174/login' -UseBasicParsing -TimeoutSec 12\\).StatusCode } catch { \"ERR $\\($_.Exception.Message\\)\" })"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Backend (FastAPI) environment reference — copy what you need into the runtime env.
|
||||
# Real values stay out of git. The dashboard seed login "Admin"/"Admin123" is
|
||||
# created only when SEED_ADMIN=1.
|
||||
|
||||
# SQLite path + reports dir (defaults are fine in Docker; a disk is mounted at /data)
|
||||
DB_PATH=/data/asianmetal.db
|
||||
REPORTS_DIR=/data/reports
|
||||
|
||||
# CORS allow-list — comma-separated origins of the frontend
|
||||
ALLOWED_ORIGINS=http://localhost:5174,https://web.didvan.com
|
||||
|
||||
# Session cookie: COOKIE_SECURE=1 behind HTTPS; COOKIE_SAMESITE=none for cross-domain
|
||||
COOKIE_SECURE=0
|
||||
COOKIE_SAMESITE=lax
|
||||
|
||||
# Dangerous development-only seed (Admin/Admin123). Explicitly opt in locally.
|
||||
SEED_ADMIN=0
|
||||
|
||||
# Only these immediate proxy networks may supply X-Real-IP/X-Forwarded-For.
|
||||
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128,172.16.0.0/12
|
||||
|
||||
# Admin panel password (admin_app only)
|
||||
ADMIN_PASSWORD=change-me-strong-secret
|
||||
|
||||
# data-source token for the daily metals/steel sync (sync.py)
|
||||
ASIANMETAL_TOKEN=
|
||||
|
||||
# Optional outbound proxy for geo-blocked sources (only needed on Iran-hosted PaaS;
|
||||
# the Arvan VPS has direct access and does NOT need these)
|
||||
# HTTP_PROXY=http://host:port
|
||||
# HTTPS_PROXY=http://host:port
|
||||
# SCRAPER_PROXY=http://host:port
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.output
|
||||
.vinxi
|
||||
.tanstack/**
|
||||
.nitro
|
||||
*.local
|
||||
|
||||
# Wrangler / Cloudflare
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
|
||||
# Secrets / env — NEVER commit (real values stay out of git)
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
# ...but DO track the documented templates (placeholders only, no secrets)
|
||||
!.env.example
|
||||
!**/.env.example
|
||||
|
||||
# SSL Certificates & keys
|
||||
certs/
|
||||
certs-git/
|
||||
certbot-www/
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Local database
|
||||
*.db
|
||||
*.sqlite*
|
||||
|
||||
# Backup files
|
||||
*.bak*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
graphify-out/
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
# 🎯 Adversarial Architecture Review
|
||||
### Closing the gaps a checklist can't see — written from the other side of the keyboard
|
||||
|
||||
> The previous checklist hardens each **component**. This document hardens the **seams between them** — the trust relationships, the assumptions, the one boundary you forgot. That's where real attacks live. A checklist asks "is this function safe?" An attacker asks "which of these 40 functions did you forget to protect, and what does owning it get me?"
|
||||
>
|
||||
> This is written attacker-first: for each gap, *here's how I'd come at it* → *here's the architectural control that shuts the door*. Every attack is paired with a fix. Nothing here is operational against anyone else's system — it's a threat model of **your** system so you can close it.
|
||||
|
||||
---
|
||||
|
||||
## 0. The mindset shift
|
||||
|
||||
A checklist is a **list of doors to lock**. An architecture review is **assuming I'm already walking your hallways and asking what I can reach**.
|
||||
|
||||
Three principles drive everything below:
|
||||
|
||||
1. **The client is enemy territory.** Anything that runs in or comes from a browser is something I control completely. Cookies, JWTs, headers, request bodies, the `user_id` field, the price — all forgeable. If a security decision happens in the browser, it doesn't happen.
|
||||
2. **Defense in depth or it's not defense.** One auth check protecting your data means one forgotten check = breach. Real security is layers where **each layer assumes the one in front of it already failed.**
|
||||
3. **Assume breach.** I *will* get a foothold somewhere — a leaked key, an XSS, a dependency. The question your architecture answers is: *then what?* How far do I get, how fast do you notice, how quickly can you kill it. Most vibe-coded apps have no answer because they were designed assuming the wall holds.
|
||||
|
||||
---
|
||||
|
||||
## 1. How I actually approach your system
|
||||
|
||||
I don't start by looking for "an XSS." I start with reconnaissance and a map. The questions I ask, in order:
|
||||
|
||||
- **What's the stack?** Response headers, JS bundle, `/_next/`, error pages, favicon hashes, DNS records — they tell me you're Next.js on Vercel with Supabase and Stripe before I've sent a single malicious byte. Now I know exactly which default mistakes to test.
|
||||
- **Where's the data, and can I reach it directly?** If I see a Supabase anon key in your JS (I will), your database is *on the internet*. I don't need to beat your app — I can query Postgres directly and see if RLS is off.
|
||||
- **Is identity proven or claimed?** I log in, watch the network tab, and look for any request where the server trusts a `user_id`, `role`, `account_id`, or `isAdmin` that *I* supplied. If changing it changes the result, you're done.
|
||||
- **What costs you money when I press a button?** Every LLM call, SMS, email, image generation, and export is a way for me to drain your wallet for free.
|
||||
- **What's the blast radius of one foothold?** If I get one key or one bug, do I get everything, or one small thing? This tells me whether you're worth my time.
|
||||
- **Will you even notice?** No logging, no alerts, no rate limits = I can take my time, brute-force, enumerate, and exfiltrate at my leisure.
|
||||
|
||||
**The architectural defense against recon:** give away as little as possible (Section 6), and make the answers to those questions *boring* — data behind a deny-by-default boundary, identity proven server-side, money gated, blast radius contained, and me visible the moment I act.
|
||||
|
||||
---
|
||||
|
||||
## 2. Map your trust boundaries (the most important diagram you will ever draw)
|
||||
|
||||
90% of breaches happen at a **trust boundary** — the line where untrusted input crosses into a zone that trusts it. If you can't draw these lines, you can't defend them. Here is the map of a typical vibe-coded app, with every boundary I attack:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ ZONE 0 — FULLY HOSTILE (you control nothing here) │
|
||||
│ Browsers · mobile apps · curl · bots · my laptop │
|
||||
│ Every byte from here is attacker-controlled: cookies, JWTs, headers, │
|
||||
│ request bodies, "user_id", "role", prices, quantities — all forgeable │
|
||||
└────────────────────────────────┬─────────────────────────────────────┘
|
||||
│ ◄══ BOUNDARY #1 (the big one)
|
||||
│ Everything crossing is a LIE until
|
||||
│ your server re-proves it.
|
||||
┌────────────────────────────────▼─────────────────────────────────────┐
|
||||
│ ZONE 1 — EDGE (the first thing you control) │
|
||||
│ CDN · WAF · DDoS protection · bot management · TLS termination │
|
||||
│ Job: drop obvious garbage before it reaches — and costs — your code. │
|
||||
└────────────────────────────────┬─────────────────────────────────────┘
|
||||
│ ◄══ BOUNDARY #2
|
||||
┌────────────────────────────────▼─────────────────────────────────────┐
|
||||
│ ZONE 2 — APPLICATION (your code) │
|
||||
│ gateway → AUTHN (prove identity) → AUTHZ (prove permission) │
|
||||
│ → input validation → business logic │
|
||||
│ Rule: identity is ESTABLISHED here from a verified session — NEVER │
|
||||
│ read from a field the client supplied. │
|
||||
└──────────────┬────────────────────────────────┬──────────────────────┘
|
||||
│ ◄══ BOUNDARY #3 │ ◄══ BOUNDARY #4
|
||||
│ (to your data) │ (to third parties)
|
||||
┌──────────────▼──────────────┐ ┌────────────▼─────────────────────┐
|
||||
│ ZONE 3 — DATA │ │ ZONE 4 — EXTERNAL │
|
||||
│ Postgres / Supabase (RLS) │ │ Stripe · LLM API · email · S3 │
|
||||
│ Object storage │ │ Each = a separate trust domain │
|
||||
│ Job: DENY BY DEFAULT, even │ │ with its own key & blast radius │
|
||||
│ when the app layer failed. │ │ Inbound (webhooks) must be │
|
||||
│ │ │ signature-verified. │
|
||||
└──────────────┬──────────────┘ └──────────────────────────────────┘
|
||||
│ ◄══ BOUNDARY #5 (the one everyone forgets)
|
||||
┌──────────────▼────────────────────────────────────────────────────────┐
|
||||
│ ZONE 5 — SECRETS & CONTROL PLANE │
|
||||
│ Env vars · secrets manager · cloud IAM · CI/CD · DNS · your GitHub │
|
||||
│ Compromise here = game over. Smallest zone, highest value, weakest │
|
||||
│ protection in most vibe-coded projects. │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Do this for your real system.** List every arrow that crosses a boundary. For each one, write down: *what does the receiving side assume about this input, and what happens if I violate that assumption?* That list is your actual attack surface — far more useful than any generic checklist.
|
||||
|
||||
---
|
||||
|
||||
## 3. The core architectural sins (these create whole *classes* of bugs)
|
||||
|
||||
Individual vulnerabilities are symptoms. These seven design decisions are the disease. Fix the architecture and entire categories of bugs become impossible instead of "remembered."
|
||||
|
||||
### Sin #1 — The client is treated as trusted
|
||||
**How I exploit it:** I open DevTools and edit anything — the price in the cart, the `user_id` in the request, the `role: "user"` in the signup body, the disabled "admin" button. Client-side validation, client-set prices, and hidden-not-protected features all fall in seconds.
|
||||
**The fix is architectural, not a patch:** The browser is a **rendering layer with zero authority**. Every decision — who you are, what you may do, what something costs, whether an action is allowed — is *re-made server-side* from server-held state. The client's job is to display and to submit intent; the server decides everything that matters.
|
||||
|
||||
### Sin #2 — Your database is on the internet and you didn't know
|
||||
**How I exploit it:** Supabase/Firebase hand the browser a key that talks *directly* to the database. That's not a bug — it's the model. But it means your DB is a public API. I take the anon key from your JS and `curl` your tables directly, bypassing your app entirely. If RLS is off (the silent default risk), I read everything.
|
||||
**The fix:** Decide consciously which model you're in. Either (a) **RLS makes the public-DB model safe** — deny by default, every table, policies scoped to `auth.uid()` — or (b) you **never expose the anon key** and route all data access through your own backend. Pick one on purpose. The disaster is doing (a)'s exposure with (b)'s assumptions.
|
||||
|
||||
### Sin #3 — Identity is claimed, not proven
|
||||
**How I exploit it:** The single most common vibe-coded flaw. The server reads `user_id` / `account_id` / `org_id` from the URL, query string, or body and trusts it. I change it to yours and read your data (horizontal escalation), or set `role=admin` (vertical escalation).
|
||||
**The fix:** Identity is **derived exclusively from the authenticated session server-side**, never from client input. The request says *what* to do; the *who* comes from the verified token only. Every resource access then checks `session.user owns/may-access this resource` as a **separate** step from authentication.
|
||||
|
||||
### Sin #4 — One layer of defense
|
||||
**How I exploit it:** If the only thing between me and your data is an `if (loggedIn)` in one route handler, I only need to find the one route where you forgot it — or the one injection that skips it. One miss = total exposure.
|
||||
**The fix:** **Layer it so my single win isn't enough.** Auth bypass in the app? RLS at the database still returns nothing for the wrong user. SQL injection? The least-privilege DB user can't read the tables it doesn't need or `DROP` anything. XSS slips through? CSP limits what the injected script can do. Each layer assumes the previous one failed (see the stack in Section 4).
|
||||
|
||||
### Sin #5 — Flat blast radius
|
||||
**How I exploit it:** I get *one* foothold — a leaked key, an SSRF, an RCE in a dependency — and because everything runs in one process with one `.env` holding the DB service-role key, the Stripe secret, *and* the OpenAI key, I now own all of it. One door opens the whole building.
|
||||
**The fix:** **Segment by blast radius.** Least privilege per component: the public web tier doesn't hold the DB admin key; the LLM-calling worker can't read the users table; the service that sends email can't touch payments. Separate keys, separate scopes, separate failure domains. One foothold should yield one small thing.
|
||||
|
||||
### Sin #6 — No idempotency or atomicity
|
||||
**How I exploit it:** I fire 50 simultaneous requests to redeem a one-time coupon, withdraw a balance, or claim limited inventory. Between your "check" and your "use" there's a gap (TOCTOU), so all 50 succeed. Or I replay your Stripe "payment succeeded" webhook ten times and get ten upgrades.
|
||||
**The fix:** Critical operations are **atomic and idempotent by design** — database constraints and transactions enforce invariants (unique coupon use, non-negative balance), idempotency keys make replays no-ops, optimistic locking handles concurrency. You can't bolt this on per-endpoint; it's how the operation is built.
|
||||
|
||||
### Sin #7 — No assume-breach posture
|
||||
**How I exploit it:** Nothing watches. No rate limits, so I brute-force and enumerate freely. No anomaly alerts, so I exfiltrate 80,000 records overnight and you find out from a customer. No revocation plan, so even after you notice, I'm in for days while you scramble.
|
||||
**The fix:** Design as if I'm already inside. **Detection** (log security events, alert on anomalies), **containment** (least privilege limits where I can go), and **response** (every secret rotatable in minutes, a kill switch for expensive features, a documented break-glass procedure). Covered in Section 7.
|
||||
|
||||
---
|
||||
|
||||
## 4. The hardened reference architecture (defense in depth as a stack)
|
||||
|
||||
To get from me at the keyboard to your data, I have to defeat **every** layer below. Each one is built assuming the layer in front of it already failed. That's the whole point — redundancy, not a single perfect wall.
|
||||
|
||||
```
|
||||
I (attacker) ──────────────────────────────────────────────────┐
|
||||
│
|
||||
┌────────────────────────────────────────────────────────────┐ │
|
||||
│ LAYER 1 · EDGE WAF · rate limit · bot mgmt · DDoS │◄┘
|
||||
│ "Assume malicious traffic reaches me."│
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ LAYER 2 · TRANSPORT TLS everywhere · HSTS · no mixed │
|
||||
│ "Assume the network is being tapped." │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ LAYER 3 · AUTHN Prove WHO · short sessions · MFA │
|
||||
│ "Assume a token will get stolen." │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ LAYER 4 · AUTHZ Prove you may touch THIS resource │
|
||||
│ "Assume an auth check was forgotten." │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ LAYER 5 · INPUT Validate · parameterize · encode │
|
||||
│ "Assume every input is an exploit." │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ LAYER 6 · DATA RLS · least-priv DB user · deny first │
|
||||
│ "Assume the query reached me raw." │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ LAYER 7 · SECRETS Segmented · least-priv · rotatable │
|
||||
│ "Assume one secret will leak." │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ LAYER 8 · DETECT Logs · anomaly alerts · kill switches │
|
||||
│ "Assume I got in. See me. Contain me."│
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
your data
|
||||
```
|
||||
|
||||
Each layer maps to a concrete owner in a serverless/BaaS stack:
|
||||
|
||||
| Layer | Where it lives (Next.js + Supabase + Stripe example) | The job, in one line |
|
||||
|------|------|------|
|
||||
| 1 Edge | Cloudflare / Vercel WAF + bot rules | Drop bots, floods, and known-bad before compute is spent |
|
||||
| 2 Transport | Platform TLS + HSTS header | No plaintext, no downgrade, no mixed content |
|
||||
| 3 AuthN | Supabase Auth / Clerk / Auth0, short-lived JWT + refresh | Establish identity from a verified session only |
|
||||
| 4 AuthZ | Your middleware + per-resource ownership checks | Prove permission for *this* object, separately from login |
|
||||
| 5 Input | Zod/Pydantic validation, parameterized queries, DOMPurify | Treat every input as hostile |
|
||||
| 6 Data | Postgres RLS + a least-privilege role | Deny by default even if layers 3–5 were bypassed |
|
||||
| 7 Secrets | Host env vars / secrets manager, scoped per service | Contain the damage of any single leaked key |
|
||||
| 8 Detect | Structured logs + alerting (Sentry, Logflare, etc.) | Notice and contain a breach in progress |
|
||||
|
||||
**The test of whether you have depth:** pick any one layer, imagine it completely fails, and ask "am I breached?" If the answer is yes for any single layer, you don't have depth there — you have a single point of failure wearing a security costume.
|
||||
|
||||
---
|
||||
|
||||
## 5. The kill chain — blocked at every stage
|
||||
|
||||
This is how an intrusion actually unfolds, start to finish, and the architectural control that breaks the chain at each link. **You only have to break the chain once** to stop the attack — but breaking it in multiple places is what "defense in depth" means in practice.
|
||||
|
||||
### Stage 1 · Reconnaissance
|
||||
- **What I do:** Fingerprint your stack, enumerate endpoints, read error messages, grab source maps, check `/.git/`, harvest the anon key from your JS, run GraphQL introspection, look for predictable IDs.
|
||||
- **What stops me:** Minimize disclosure (Section 6). Generic errors, no source maps in prod, no `/.git/` exposure, introspection disabled, UUIDs not sequential IDs, no stack/version leakage. Make recon return nothing useful.
|
||||
|
||||
### Stage 2 · Initial access / unauthenticated probing
|
||||
- **What I do:** Hit your API directly with no auth, query your DB with the anon key, spray credential-stuffing lists at login, look for any endpoint that responds with data before checking who I am.
|
||||
- **What stops me:** Edge WAF + bot management absorbs the spray. Auth boundary returns 401 before any handler logic. RLS returns empty to the anon key. Rate limiting throttles credential stuffing to uselessness.
|
||||
|
||||
### Stage 3 · Authentication bypass / session theft
|
||||
- **What I do:** Steal a JWT via XSS, replay a non-expiring token, reuse a session that "logged out" only client-side, forge a weakly-signed token.
|
||||
- **What stops me:** Short-lived access tokens + server-side refresh/revocation. Sessions invalidated server-side on logout. `httpOnly` cookies so XSS can't read them. Strong signing secrets. Re-authentication required for sensitive actions (changing email, payment, deleting account).
|
||||
|
||||
### Stage 4 · Authorization bypass (IDOR / privilege escalation) — *the money shot*
|
||||
- **What I do:** Change a resource ID to yours and read your data. Set `role=admin` or `is_verified=true` via mass assignment. Hit an admin route with a normal session.
|
||||
- **What stops me:** Identity from the session only (never client input). A separate ownership check on every resource (read *and* write) returning 403. Field whitelisting (no mass assignment). Admin checks server-side on every admin route. **And** — because of depth — RLS at the data layer denies cross-user access even if the app check was forgotten.
|
||||
|
||||
### Stage 5 · Injection
|
||||
- **What I do:** SQL injection via concatenated queries, command injection via `exec`, NoSQL operator injection, SSTI, path traversal, XXE.
|
||||
- **What stops me:** Parameterized queries everywhere (no concatenation). No user input into shell/eval. Validation at layer 5. WAF catches common payloads at the edge. **And** the least-privilege DB user means even successful injection can't read tables it doesn't need or drop anything — depth again.
|
||||
|
||||
### Stage 6 · Lateral movement / privilege expansion
|
||||
- **What I do:** From one foothold, pivot — use the leaked key to reach other services, read other secrets from the shared env, move from the web tier into the data tier, abuse an over-permissioned service account.
|
||||
- **What stops me:** **Segmentation and least privilege.** Each component holds only the keys it needs, scoped to only what it needs. The LLM worker can't read users; the email service can't touch payments; the web tier doesn't have the DB admin key. My one foothold is a dead end, not a hallway.
|
||||
|
||||
### Stage 7 · Exfiltration
|
||||
- **What I do:** Pull every record via a list endpoint, walk sequential IDs, scrape the whole site, export bulk data, siphon it out slowly to stay under the radar.
|
||||
- **What stops me:** No endpoint returns "all rows" — pagination caps + per-account volume limits. RLS scopes every read to the user. Anomaly detection flags one client pulling 50,000 records. Egress monitoring. Scraping patterns (sequential walking, high-volume single client) trigger throttling and alerts.
|
||||
|
||||
### Stage 8 · Denial of wallet (the modern DoS)
|
||||
- **What I do:** Spam your LLM agent, trigger thousands of SMS/emails, force expensive image generation, blow up your cloud egress — not to take you down, but to *bankrupt* you.
|
||||
- **What stops me:** Every paid downstream action is behind a **quota + circuit breaker + hard spend cap** (Section 8). Per-user limits on agent calls, max tokens, daily budgets, and a kill switch that stops calling the expensive service when a threshold trips.
|
||||
|
||||
### Stage 9 · Persistence
|
||||
- **What I do:** Create a hidden admin account, plant a malicious webhook, add an API key, inject a stored payload that runs later (e.g. a poisoned prompt your agent re-executes, or stored XSS), drop an SSH key.
|
||||
- **What stops me:** Audit logging of every privilege-changing action with alerts (new admin, new key, new webhook → you get pinged). Immutable audit trail. Stored content treated as untrusted on read. Periodic review of accounts, keys, and webhooks.
|
||||
|
||||
### Stage 10 · Covering tracks
|
||||
- **What I do:** Delete or edit logs to hide what I did.
|
||||
- **What stops me:** Logs shipped **off-box** to an append-only/immutable store I can't reach from the app tier. If the logging sink is in a different trust zone than the thing being logged, I can't clean up after myself.
|
||||
|
||||
---
|
||||
|
||||
## 6. The seams a checklist misses (integration trust)
|
||||
|
||||
Checklists audit boxes. I attack the **arrows between boxes** — every place one component trusts another. These are the highest-value, least-audited targets.
|
||||
|
||||
- **Webhook → your DB (Stripe, etc.):** The arrow assumes "this really came from Stripe." I send a forged `payment.succeeded` with `curl`. *Close it:* verify the signature on every webhook (reject 400 on bad/missing), enforce idempotency on event IDs, derive entitlements from verified events only — never a client redirect.
|
||||
- **AI agent → tools → your data:** The arrow assumes the model's tool calls are safe. I use prompt injection (direct, or hidden in a document the agent reads) to make it call a tool on *your* data or take a destructive action. *Close it:* scope every tool to the current user with least privilege, validate model-generated tool inputs like any user input, require confirmation for destructive actions, sandbox any code execution, and keep authorization in your code — never in the prompt.
|
||||
- **Upload → storage → served back:** The arrow assumes an uploaded "image" is an image. I upload a polyglot/HTML file and get it served from your origin to run as same-origin script. *Close it:* validate by magic bytes, rename to UUID, re-encode images, and serve from a **separate origin/bucket** so nothing user-uploaded executes in your security context.
|
||||
- **CDN cache → private data:** The arrow assumes responses are safe to cache. You cache an authenticated response and the CDN serves User A's private data to User B. *Close it:* explicit `Cache-Control: private/no-store` on all authenticated responses, `Vary` correctly, never cache anything keyed to a user at a shared layer.
|
||||
- **Service → service (internal calls):** The arrow assumes internal callers are trusted because they're "internal." I find an SSRF or get a foothold and call your internal endpoints, which trust a header like `X-User-Id`. *Close it:* internal services authenticate each other (signed requests / mTLS / a shared secret), and **never** trust client-or-peer-supplied identity headers without verification. There is no "internal therefore trusted."
|
||||
- **SSO / OAuth callback:** The arrow assumes the callback is legitimate. I tamper with `redirect_uri`, skip the `state` check (CSRF), or replay a code. *Close it:* strict `redirect_uri` allowlist, mandatory `state` parameter, PKCE, single-use codes, validate the token audience.
|
||||
- **DNS / subdomain → hosting:** The arrow assumes your DNS records point where you think. You delete a Vercel/S3/Heroku app but leave the `CNAME`; I claim the dangling target and now host phishing on *your* subdomain. *Close it:* audit DNS for dangling records, remove records when you tear down resources, registrar lock, CAA records, MFA on your DNS provider.
|
||||
- **WebSocket / real-time:** The arrow assumes the socket is authed like your HTTP routes. It usually isn't — WS connections frequently skip authn/authz entirely. *Close it:* authenticate on connect, authorize every message, rate-limit per connection, scope subscriptions to the user.
|
||||
- **Email / notifications → the world:** The arrow assumes your send features are used normally. I abuse "invite a friend" or password-reset to email-bomb a victim, or inject headers via a contact form. *Close it:* rate-limit and cap send features per user, validate/encode anything that reaches an email header, and configure SPF/DKIM/DMARC so no one can send *as* your domain.
|
||||
- **Third-party scripts → your page:** The arrow assumes your analytics/chat/widget vendors are safe. Their compromise becomes yours (supply-chain via the browser). *Close it:* Subresource Integrity on pinned scripts, a strict CSP, minimize third-party JS, and treat every embedded vendor as code running with your users' trust.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7. Assume breach — blast-radius containment & response
|
||||
|
||||
Everything above tries to keep me out. This section accepts that **someday I get in** and asks the only question that matters then: *how bad is it, and how fast can you end it?* This is what separates an app that has "a breach" from one that has "a catastrophe."
|
||||
|
||||
**Contain the blast radius (limit where one foothold reaches):**
|
||||
- [ ] **Secret segmentation.** No single `.env` holds the keys to everything. The DB service-role key, the Stripe secret, and the LLM key live in *different* trust zones, used by *different* components. One leak ≠ total compromise.
|
||||
- [ ] **Least privilege, everywhere.** Every key, role, and service account can touch only what it needs. Read-only where possible. No `AdministratorAccess` on app roles. The DB user your app connects with can't drop tables or read tables it doesn't use.
|
||||
- [ ] **Short-lived credentials.** Prefer rotating/temporary tokens over long-lived static keys. A stolen credential that expires in an hour is worth far less than one that lives forever.
|
||||
- [ ] **Isolation between tenants/users.** One user's compromise (or one user *being* me) can't reach another's data — enforced at the data layer (RLS), not just the app.
|
||||
|
||||
**Detect me (assume I'm acting right now):**
|
||||
- [ ] **Log security events off-box:** auth failures, 403s, rate-limit hits, admin actions, key/webhook creation, spend spikes — shipped to a store the app tier can't edit.
|
||||
- [ ] **Alert on anomalies:** 100× traffic spike, logins from new geographies, bulk data access, spend crossing a threshold, a new admin or API key appearing. The alert should reach a human (or a kill switch) in minutes, not days.
|
||||
- [ ] **Baseline normal**, so abnormal is visible. You can't detect "weird" without knowing "normal."
|
||||
|
||||
**End it (have the plan before you need it):**
|
||||
- [ ] **Rotate everything in minutes.** A documented, tested procedure to roll every secret — DB creds, API keys, signing secrets, OAuth secrets. If rotating your DB password would take you a panicked afternoon, you've already lost.
|
||||
- [ ] **Kill switches.** Feature flags / env toggles that instantly disable expensive or risky surfaces (the AI agent, file uploads, a leaking endpoint) without a redeploy.
|
||||
- [ ] **Break-glass runbook.** Written steps: how to take the app offline, revoke sessions, rotate keys, preserve evidence (don't destroy the logs you'll need), and notify users. Decide this calm, not mid-incident.
|
||||
- [ ] **Backups that survive ransomware.** Immutable/offline copies, and a restore you've **actually tested**. A backup you've never restored is a wish.
|
||||
|
||||
---
|
||||
|
||||
## 8. The economic attack surface (denial of wallet)
|
||||
|
||||
A modern attacker doesn't always want to steal your data or take you down — sometimes they just want to **make your bill explode**, for lulz or extortion. Vibe-coded apps are uniquely exposed because they wire up metered services (LLMs, SMS, email, image gen, egress) with no economic guardrails.
|
||||
|
||||
**The design principle:** inventory **every action a user can trigger that costs *you* money**, and gate each one. If pressing a button calls a paid API, an attacker pressing it 100,000 times is a denial-of-wallet attack.
|
||||
|
||||
- [ ] **Map the cost surface.** Make a literal list: LLM calls, embeddings, SMS, email, image/video generation, third-party API calls, cloud egress, heavy compute jobs. Each line is an attack vector.
|
||||
- [ ] **Per-user quotas + rate limits** on every one of them. One user cannot consume the whole budget.
|
||||
- [ ] **Hard spend caps** at the provider (OpenAI/Anthropic/Twilio billing limits) *and* a server-side **circuit breaker** that stops calling out when a daily/hourly threshold trips.
|
||||
- [ ] **Bounded inputs/outputs:** max input length, max output tokens, max file size, max job duration — so a single request can't be made arbitrarily expensive.
|
||||
- [ ] **Anomaly alerts on spend**, so a 50× cost spike pages you within minutes instead of arriving as a month-end invoice.
|
||||
- [ ] **Decompression/zip-bomb and complexity limits** so one cheap request can't trigger ruinous server work.
|
||||
|
||||
---
|
||||
|
||||
## 9. Atomicity, idempotency & race conditions (business logic at the architecture level)
|
||||
|
||||
This is the category AI tools and checklists are *worst* at, because it's about *your* rules and *concurrent* behavior — invisible when you read code top-to-bottom. It's how I get free money without "hacking" anything.
|
||||
|
||||
**Race conditions / TOCTOU (time-of-check to time-of-use):**
|
||||
- **How I exploit it:** I send many requests *simultaneously* so they all pass the "check" before any of them "uses." Redeem a one-time coupon 50 times. Withdraw a $100 balance 50 times. Claim the last 1 inventory item 50 times. Refer-and-reward myself in a loop.
|
||||
- **The fix is at the data layer, not the handler:** Enforce invariants with **database constraints and transactions** (unique constraint on coupon-use, `CHECK balance >= 0`, atomic decrement with row locking). Optimistic or pessimistic locking on contended rows. The rule can't live in app-level "check then write" — that gap is the vulnerability.
|
||||
|
||||
**Idempotency / replay:**
|
||||
- **How I exploit it:** I replay the same request — a payment confirmation, a webhook, a "claim reward" — and get the effect twice (or N times). Network retries can even trigger this by accident.
|
||||
- **The fix:** **Idempotency keys** on state-changing operations (the same key = the same single effect, no matter how many times it arrives). Signed + timestamped + nonce'd requests to reject replays. Stored processed-event IDs for webhooks.
|
||||
|
||||
**Workflow / state-machine bypass:**
|
||||
- **How I exploit it:** I skip steps — reach a paid feature without completing payment, hit "confirm" without "review," jump to a later state by calling its endpoint directly.
|
||||
- **The fix:** Enforce state transitions **server-side** as an explicit state machine; each step verifies the prior state actually holds. Don't assume users walk your happy path — I won't.
|
||||
|
||||
**Value/quantity tampering:**
|
||||
- **How I exploit it:** Negative quantities to get a credit, fractional cents and rounding abuse, currency confusion, a price field I supplied.
|
||||
- **The fix:** Server-side validation of ranges and signs; prices and totals computed server-side from trusted data; decimal/money types, never floats for currency.
|
||||
|
||||
---
|
||||
|
||||
## 10. If I were attacking you tomorrow — my first 12 moves
|
||||
|
||||
A concrete red-team walk against a typical vibe-coded stack (Next.js + Supabase + Stripe + an LLM agent). For each: what I try, why it usually works, and the architectural control that turns it into a dead end. *(Patterns only — this is your threat model, not a weapon against others.)*
|
||||
|
||||
1. **Fingerprint the stack** from headers/bundle/DNS. → *Dead end:* nothing sensitive in headers, no source maps, generic errors. I learn nothing.
|
||||
2. **Pull the Supabase anon key** from your JS and `curl` your tables directly. → *Dead end:* RLS denies by default; I get empty arrays. (This one move breaks ~half of vibe-coded apps. Make sure it breaks on yours.)
|
||||
3. **Map every API route** and hit each one **unauthenticated**, looking for one that returns data. → *Dead end:* 401 before any handler logic, on every protected route.
|
||||
4. **Log in and tamper with identity** — change `user_id`/`org_id` in requests, flip `role` in the signup body. → *Dead end:* identity comes from the session only; ownership checked per resource; field whitelisting blocks role injection; RLS backstops it.
|
||||
5. **Walk resource IDs** (`/orders/1001`, `1002`, …) to read others' data. → *Dead end:* UUIDs + ownership checks + RLS; enumeration yields nothing.
|
||||
6. **Credential-stuff the login** with a breach list. → *Dead end:* rate limiting + bot management + MFA make it uneconomical.
|
||||
7. **Hammer the AI agent** with a request flood and oversized prompts to spike your LLM bill, and try **prompt injection** to make it leak data or call a tool. → *Dead end:* per-user quotas + token caps + spend circuit breaker; tools scoped to me with least privilege; injection can't change authorization because that lives in your code.
|
||||
8. **Forge a Stripe webhook** (`payment.succeeded`) to grant myself a paid plan, then **replay** a real one for extra. → *Dead end:* signature verification rejects the forgery; idempotency rejects the replay.
|
||||
9. **Race a one-time action** — coupon, balance, inventory — with 50 concurrent requests. → *Dead end:* DB constraints + atomic transactions; only one succeeds.
|
||||
10. **Upload a disguised file** (HTML/script named `.jpg`) and load it from your origin. → *Dead end:* magic-byte validation + re-encode + served from a separate bucket; nothing runs in your context.
|
||||
11. **Probe the seams** — SSRF to `169.254.169.254` for cloud creds, internal endpoints trusting `X-User-Id`, a dangling DNS subdomain. → *Dead end:* SSRF blocks private IPs; internal calls are authenticated; DNS has no dangling records.
|
||||
12. **Exfiltrate in bulk** via a list endpoint or slow scrape. → *Dead end:* pagination caps + per-account volume limits + anomaly alerts; I get flagged and throttled long before I get your dataset.
|
||||
|
||||
**If even half of these are dead ends on your system, you're already past the bar that took down every app on the breach lists.** The ones that aren't dead ends yet — fix those first; they're your real exposure.
|
||||
|
||||
---
|
||||
|
||||
## 11. Architecture Decision Records (commit these — they're load-bearing)
|
||||
|
||||
Turn the principles above into **binding decisions** you write down and don't relitigate. When your AI agent (or future you) is about to violate one, the ADR is what stops it.
|
||||
|
||||
- **AD-1 · The client has zero authority.** Every security decision is re-made server-side from server-held state. The browser displays and submits intent; it decides nothing.
|
||||
- **AD-2 · The data layer denies by default.** RLS on every table, scoped to `auth.uid()`. The database protects itself even if the app layer is wrong.
|
||||
- **AD-3 · Identity is proven, never claimed.** The authenticated user comes from the verified session only — never from a client-supplied field.
|
||||
- **AD-4 · Authorization is separate from authentication.** Every resource access independently verifies the user may touch *this* object, on reads and writes.
|
||||
- **AD-5 · Secrets are segmented by blast radius.** No component holds a key it doesn't use; one leak compromises one thing, not everything.
|
||||
- **AD-6 · Every metered action is gated.** Quota + rate limit + spend cap + circuit breaker on anything that costs money.
|
||||
- **AD-7 · State-changing operations are atomic and idempotent.** Invariants enforced at the data layer; replays are no-ops.
|
||||
- **AD-8 · Trust boundaries are explicit and authenticated.** No "internal therefore trusted." Every cross-boundary call verifies the caller; every inbound webhook verifies its signature.
|
||||
- **AD-9 · Assume breach.** Log off-box, alert on anomalies, and keep every secret rotatable in minutes with a kill switch and a runbook.
|
||||
- **AD-10 · Minimize disclosure.** Generic errors, no source maps, no introspection in prod, non-sequential IDs, nothing in recon that helps an attacker.
|
||||
|
||||
---
|
||||
|
||||
## 12. How I'd verify these actually hold (red-team test plan)
|
||||
|
||||
Architecture controls are worthless if they're aspirational. Prove each boundary holds with an adversarial test — not "does the feature work" but "does the *failure mode* fail closed":
|
||||
|
||||
- [ ] **Boundary #1 (client→server):** tamper with every client-supplied field (`user_id`, `role`, price, quantity) and confirm the server ignores or rejects it.
|
||||
- [ ] **Boundary #3 (app→data):** query the DB with only the anon key; confirm RLS returns nothing for data you shouldn't see.
|
||||
- [ ] **Depth check:** disable the app-layer auth check in a test and confirm RLS *still* blocks cross-user reads. (If it doesn't, you have one layer, not two.)
|
||||
- [ ] **AuthZ:** with User A's session, attempt to read and write User B's resources on every endpoint that takes an ID → all 403.
|
||||
- [ ] **Seams:** send an unsigned webhook (→400), replay a signed one (→ no double effect), try SSRF to internal IPs (→ blocked), upload a disguised file (→ can't execute on your origin).
|
||||
- [ ] **Race:** fire concurrent requests at every one-time/limited action and confirm exactly one succeeds.
|
||||
- [ ] **Denial of wallet:** burst the agent and metered endpoints; confirm quotas, caps, and the circuit breaker trip.
|
||||
- [ ] **Blast radius:** assume one key leaked — trace on paper exactly what it reaches. If "everything," fix the segmentation.
|
||||
- [ ] **Detection:** run the above and check that your logging/alerting actually *noticed*. Silent controls you can't observe failing are controls you can't trust.
|
||||
|
||||
When you've turned all of these into dead ends, **bring in a human pentester** to find the things a threat model can't — business-logic flaws specific to your domain, chained low-severity bugs, and the creative paths neither of us listed. That's the one step no document replaces.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the architecture in one breath
|
||||
|
||||
> The client is hostile and decides nothing. Identity is proven server-side, never claimed. Every resource access is authorized separately, on a data layer that denies by default — so one forgotten check isn't a breach. Secrets are segmented so one leak isn't game over. Every action that costs money is gated. State changes are atomic and idempotent. Every trust boundary is explicit and authenticated. And you assume I'm already inside — so you log, alert, contain, and can rotate everything in minutes. **Make my first twelve moves dead ends, then hire someone to find the thirteenth.**
|
||||
|
||||
---
|
||||
|
||||
*Companion to the SECURITY-CHECKLIST. The checklist hardens components; this hardens the architecture and the seams between them. Neither makes you "unhackable" — together they make you a hard, contained, well-monitored target, which is the real goal. When you have real users and revenue, a human penetration test is the next step, not an optional one.*
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
# مستندات API قیمت ها
|
||||
|
||||
این API برای دریافت قیمت ها و داده های بازار به صورت JSON ساخته شده است.
|
||||
تمام endpoint های اشتراکی با `/api/v1` شروع می شوند و فقط خواندنی هستند.
|
||||
|
||||
## 1. آدرس پایه
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir
|
||||
```
|
||||
|
||||
## 2. احراز هویت
|
||||
|
||||
برای تمام درخواست ها باید هدر زیر ارسال شود:
|
||||
|
||||
```http
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
```
|
||||
|
||||
اگر این هدر ارسال نشود یا توکن اشتباه باشد، پاسخ `401` برمی گردد.
|
||||
|
||||
نمونه:
|
||||
|
||||
```js
|
||||
const TOKEN = "YOUR_API_TOKEN";
|
||||
|
||||
const res = await fetch("https://statista.steelforesight.ir/api/v1/prices/latest", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
```
|
||||
|
||||
## 3. دریافت همه داده ها در یک درخواست
|
||||
|
||||
```http
|
||||
GET /api/v1/prices/latest
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/prices/latest
|
||||
```
|
||||
|
||||
این endpoint همه دیتاست ها را در یک پاسخ JSON برمی گرداند.
|
||||
|
||||
نمونه ساختار پاسخ:
|
||||
|
||||
```json
|
||||
{
|
||||
"generated_at": "2026-07-04T10:50:00Z",
|
||||
"datasets": {
|
||||
"metals": [],
|
||||
"live_commodities": [],
|
||||
"currency": [],
|
||||
"gold": [],
|
||||
"coin": [],
|
||||
"commodity": [],
|
||||
"steel_stocks": [],
|
||||
"crypto": {},
|
||||
"world_economy": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
معنی هر کلید:
|
||||
|
||||
| کلید | منبع | توضیح |
|
||||
|---|---|---|
|
||||
| `metals` | دیتابیس داخلی | محصولات فلزی و فولادی |
|
||||
| `currency` | TGJU | ارز |
|
||||
| `gold` | TGJU | طلا و نقره |
|
||||
| `coin` | TGJU | سکه |
|
||||
| `commodity` | Yahoo Finance | کامودیتی های جهانی مثل نفت، طلا، مس و غیره |
|
||||
| `steel_stocks` | CompaniesMarketCap | شرکت های فولادی جهان |
|
||||
| `crypto` | CoinGecko | کریپتو، مارکت جهانی و ترندها |
|
||||
| `world_economy` | Trading Economics | شاخص های اقتصاد کلان کشورها |
|
||||
| `live_commodities` | داخلی/لایو | دیتاست کامودیتی زنده، اگر فعال باشد |
|
||||
|
||||
## 4. محصولات فلزی و فولادی
|
||||
|
||||
### 4.1 فهرست کامل گروه ها، دسته ها و محصولات
|
||||
|
||||
```http
|
||||
GET /api/v1/metals/tree
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/metals/tree
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
{
|
||||
"Minor Metals": {
|
||||
"Lithium": [
|
||||
"Lithium Carbonate 99.5%min China",
|
||||
"Lithium Hydroxide 56.5%min China"
|
||||
],
|
||||
"Cobalt": [
|
||||
"Cobalt Metal 99.8%min China"
|
||||
]
|
||||
},
|
||||
"Carbon Steel": {
|
||||
"Rebar": [
|
||||
"Rebar HRB400 20mm China"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
تعداد محصولات فلزی موجود در کاتالوگ داخلی:
|
||||
|
||||
```txt
|
||||
674 نام محصول یکتا
|
||||
686 رکورد در گروه/دسته، چون بعضی محصولات در بیش از یک دسته دیده می شوند
|
||||
```
|
||||
|
||||
### 4.2 آخرین قیمت همه محصولات فلزی
|
||||
|
||||
```http
|
||||
GET /api/v1/metals/latest
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/metals/latest
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"group": "Minor Metals",
|
||||
"category": "Lithium",
|
||||
"title": "Lithium Carbonate 99.5%min China",
|
||||
"date": "2026-07-04",
|
||||
"low": 72000,
|
||||
"mid": 73500,
|
||||
"high": 75000,
|
||||
"prev_mid": 73000,
|
||||
"pct": 0.68
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
توضیح فیلدها:
|
||||
|
||||
| فیلد | توضیح |
|
||||
|---|---|
|
||||
| `group` | گروه اصلی محصول |
|
||||
| `category` | دسته محصول |
|
||||
| `title` | نام محصول |
|
||||
| `date` | تاریخ آخرین قیمت |
|
||||
| `low` | کمترین قیمت |
|
||||
| `mid` | قیمت میانی |
|
||||
| `high` | بیشترین قیمت |
|
||||
| `prev_mid` | قیمت میانی قبلی |
|
||||
| `pct` | درصد تغییر نسبت به قیمت قبلی |
|
||||
|
||||
### 4.3 تاریخچه قیمت یک محصول
|
||||
|
||||
```http
|
||||
GET /api/v1/metals/history?title=PRODUCT_TITLE&days=365
|
||||
```
|
||||
|
||||
پارامترها:
|
||||
|
||||
| پارامتر | توضیح |
|
||||
|---|---|
|
||||
| `title` | نام دقیق محصول |
|
||||
| `days` | تعداد روزهای تاریخچه. اگر `0` باشد، همه تاریخچه برمی گردد |
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"date": "2026-07-01",
|
||||
"low": 71000,
|
||||
"mid": 72500,
|
||||
"high": 74000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 4.4 جستجوی محصول
|
||||
|
||||
```http
|
||||
GET /api/v1/metals/search?q=lithium
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "Lithium Carbonate 99.5%min China",
|
||||
"group": "Minor Metals",
|
||||
"category": "Lithium"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 5. TGJU: ارز، طلا و سکه
|
||||
|
||||
داده های TGJU در سه endpoint جدا ارائه می شوند.
|
||||
|
||||
### 5.1 ارز
|
||||
|
||||
```http
|
||||
GET /api/v1/currency
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/currency
|
||||
```
|
||||
|
||||
### 5.2 طلا
|
||||
|
||||
```http
|
||||
GET /api/v1/gold
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/gold
|
||||
```
|
||||
|
||||
### 5.3 سکه
|
||||
|
||||
```http
|
||||
GET /api/v1/coin
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/coin
|
||||
```
|
||||
|
||||
نمونه پاسخ TGJU:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "دلار / نقدی",
|
||||
"price": 610000,
|
||||
"change_val": "(0%) 0",
|
||||
"pct": 0,
|
||||
"dir": "up",
|
||||
"fetched_at": "2026-07-04T10:41:25Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
توضیح فیلدها:
|
||||
|
||||
| فیلد | توضیح |
|
||||
|---|---|
|
||||
| `name` | نام آیتم |
|
||||
| `price` | قیمت عددی |
|
||||
| `change_val` | متن تغییر قیمت طبق منبع |
|
||||
| `pct` | درصد تغییر |
|
||||
| `dir` | جهت تغییر، مثل `up` یا `down` |
|
||||
| `fetched_at` | زمان دریافت داده |
|
||||
|
||||
در endpoint کلی:
|
||||
|
||||
```js
|
||||
data.datasets.currency
|
||||
data.datasets.gold
|
||||
data.datasets.coin
|
||||
```
|
||||
|
||||
## 6. CoinGecko: کریپتو
|
||||
|
||||
```http
|
||||
GET /api/v1/crypto
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/crypto
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
{
|
||||
"coins": [
|
||||
{
|
||||
"id": "bitcoin",
|
||||
"symbol": "BTC",
|
||||
"name": "Bitcoin",
|
||||
"rank": 1,
|
||||
"price": 65000,
|
||||
"pct_1h": 0.1,
|
||||
"pct_24h": 1.2,
|
||||
"pct_7d": -3.4,
|
||||
"market_cap": 1280000000000,
|
||||
"volume_24h": 32000000000,
|
||||
"high_24h": 66000,
|
||||
"low_24h": 64000,
|
||||
"sparkline": [],
|
||||
"dir": "up"
|
||||
}
|
||||
],
|
||||
"global": {
|
||||
"market_cap_usd": 2500000000000,
|
||||
"volume_usd": 90000000000,
|
||||
"btc_dominance": 51.2,
|
||||
"eth_dominance": 17.4
|
||||
},
|
||||
"trending": [],
|
||||
"fetched_at": "2026-07-04T10:41:25Z"
|
||||
}
|
||||
```
|
||||
|
||||
در endpoint کلی:
|
||||
|
||||
```js
|
||||
data.datasets.crypto
|
||||
```
|
||||
|
||||
## 7. CompaniesMarketCap: سهام شرکت های فولادی
|
||||
|
||||
```http
|
||||
GET /api/v1/steel-stocks
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/steel-stocks
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"code": "NUE",
|
||||
"rank": 1,
|
||||
"name": "Nucor",
|
||||
"market_cap": 38.5,
|
||||
"price": 156.2,
|
||||
"today_pct": 1.4,
|
||||
"dir": "up",
|
||||
"country": "United States",
|
||||
"fetched_at": "2026-07-04T10:41:25Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
در endpoint کلی:
|
||||
|
||||
```js
|
||||
data.datasets.steel_stocks
|
||||
```
|
||||
|
||||
## 8. Yahoo Finance: کامودیتی های جهانی
|
||||
|
||||
```http
|
||||
GET /api/v1/commodity
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/commodity
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"symbol": "GC=F",
|
||||
"name": "Gold",
|
||||
"price": 2350.5,
|
||||
"change": 8.2,
|
||||
"pct": 0.35,
|
||||
"currency": "USD",
|
||||
"up": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
در endpoint کلی:
|
||||
|
||||
```js
|
||||
data.datasets.commodity
|
||||
```
|
||||
|
||||
## 9. Trading Economics: اقتصاد جهان
|
||||
|
||||
```http
|
||||
GET /api/v1/world-economy
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/world-economy
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"country": "United States",
|
||||
"gdp": 27360,
|
||||
"gdp_growth": 1.4,
|
||||
"interest_rate": 5.5,
|
||||
"inflation_rate": 3.2,
|
||||
"jobless_rate": 4.0,
|
||||
"gov_budget": -6.3,
|
||||
"debt_gdp": 122.3,
|
||||
"current_account": -3.0,
|
||||
"population": 335,
|
||||
"fetched_at": "2026-07-04T10:41:25Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
در endpoint کلی:
|
||||
|
||||
```js
|
||||
data.datasets.world_economy
|
||||
```
|
||||
|
||||
## 10. کشف endpoint های موجود
|
||||
|
||||
```http
|
||||
GET /api/v1/datasets
|
||||
```
|
||||
|
||||
آدرس کامل:
|
||||
|
||||
```txt
|
||||
https://statista.steelforesight.ir/api/v1/datasets
|
||||
```
|
||||
|
||||
این endpoint لیست مسیرهای API را برمی گرداند.
|
||||
|
||||
## 11. نمونه کامل JavaScript
|
||||
|
||||
```js
|
||||
const API = "https://statista.steelforesight.ir";
|
||||
const TOKEN = "YOUR_API_TOKEN";
|
||||
|
||||
async function getJson(path) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const allPrices = await getJson("/api/v1/prices/latest");
|
||||
|
||||
console.log(allPrices.datasets.metals);
|
||||
console.log(allPrices.datasets.currency);
|
||||
console.log(allPrices.datasets.gold);
|
||||
console.log(allPrices.datasets.coin);
|
||||
console.log(allPrices.datasets.crypto);
|
||||
console.log(allPrices.datasets.steel_stocks);
|
||||
console.log(allPrices.datasets.commodity);
|
||||
console.log(allPrices.datasets.world_economy);
|
||||
```
|
||||
|
||||
## 12. نمونه تست با PowerShell
|
||||
|
||||
```powershell
|
||||
$TOKEN = "YOUR_API_TOKEN"
|
||||
|
||||
$data = Invoke-RestMethod `
|
||||
-Uri "https://statista.steelforesight.ir/api/v1/prices/latest" `
|
||||
-Headers @{ Authorization = "Bearer $TOKEN" }
|
||||
|
||||
$data.datasets.currency | Select-Object -First 5
|
||||
$data.datasets.gold | Select-Object -First 5
|
||||
$data.datasets.coin | Select-Object -First 5
|
||||
```
|
||||
|
||||
اگر متن فارسی در PowerShell خراب نمایش داده شد، مشکل از encoding ترمینال است. برای بهتر شدن نمایش:
|
||||
|
||||
```powershell
|
||||
chcp 65001
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
```
|
||||
|
||||
## 13. وضعیت فعلی دیتاست های Live
|
||||
|
||||
طبق آخرین خروجی live API:
|
||||
|
||||
```txt
|
||||
TGJU currency: 33
|
||||
TGJU gold: 14
|
||||
TGJU coin: 5
|
||||
Yahoo Finance commodities: 19
|
||||
CompaniesMarketCap steel stocks: 81
|
||||
CoinGecko top crypto: 100
|
||||
CoinGecko trending: 15
|
||||
```
|
||||
|
||||
کاتالوگ داخلی فلزات:
|
||||
|
||||
```txt
|
||||
674 نام محصول فلزی/فولادی یکتا
|
||||
```
|
||||
|
||||
|
|
@ -0,0 +1,485 @@
|
|||
# مستند API اشتراک قیمت ها
|
||||
|
||||
این API برای دریافت خروجی JSON از قیمت ها و داده های بازار استفاده می شود.
|
||||
همه endpoint های نسخه اشتراکی با `/api/v1` شروع می شوند و فقط خواندنی هستند.
|
||||
|
||||
## اطلاعات اصلی
|
||||
|
||||
Base URL:
|
||||
|
||||
```txt
|
||||
https://YOUR-DOMAIN.com
|
||||
```
|
||||
|
||||
Header احراز هویت:
|
||||
|
||||
```http
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
```
|
||||
|
||||
توکن در بک اند از متغیر محیطی زیر خوانده می شود:
|
||||
|
||||
```txt
|
||||
API_TOKEN
|
||||
```
|
||||
|
||||
اگر توکن ارسال نشود یا اشتباه باشد، پاسخ `401` برمی گردد.
|
||||
اگر توکن روی سرور تنظیم نشده باشد، پاسخ `503` برمی گردد.
|
||||
|
||||
## دریافت همه قیمت ها در یک درخواست
|
||||
|
||||
```http
|
||||
GET /api/v1/prices/latest
|
||||
```
|
||||
|
||||
این endpoint همه دیتاست های اصلی را در یک JSON واحد برمی گرداند.
|
||||
|
||||
نمونه درخواست JavaScript:
|
||||
|
||||
```js
|
||||
const API = "https://YOUR-DOMAIN.com";
|
||||
const TOKEN = "YOUR_API_TOKEN";
|
||||
|
||||
const response = await fetch(`${API}/api/v1/prices/latest`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data.generated_at);
|
||||
console.log(data.datasets.metals);
|
||||
console.log(data.datasets.currency);
|
||||
```
|
||||
|
||||
ساختار کلی پاسخ:
|
||||
|
||||
```json
|
||||
{
|
||||
"generated_at": "2026-07-04T09:30:00Z",
|
||||
"datasets": {
|
||||
"metals": [],
|
||||
"live_commodities": [],
|
||||
"currency": [],
|
||||
"gold": [],
|
||||
"coin": [],
|
||||
"commodity": [],
|
||||
"steel_stocks": [],
|
||||
"crypto": {},
|
||||
"world_economy": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## فهرست کامل محصولات فلزی
|
||||
|
||||
برای دیدن همه گروه ها، دسته ها و نام محصولات:
|
||||
|
||||
```http
|
||||
GET /api/v1/metals/tree
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
{
|
||||
"Minor Metals": {
|
||||
"Lithium": [
|
||||
"Lithium Carbonate 99.5%min China",
|
||||
"Lithium Hydroxide 56.5%min China"
|
||||
],
|
||||
"Cobalt": [
|
||||
"Cobalt Metal 99.8%min China"
|
||||
]
|
||||
},
|
||||
"Carbon Steel": {
|
||||
"Rebar": [
|
||||
"Rebar HRB400 20mm China"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
تعداد فعلی محصولات فلزی در دیتابیس اصلی: **674 محصول**.
|
||||
|
||||
## آخرین قیمت همه محصولات فلزی
|
||||
|
||||
```http
|
||||
GET /api/v1/metals/latest
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"group": "Minor Metals",
|
||||
"category": "Lithium",
|
||||
"title": "Lithium Carbonate 99.5%min China",
|
||||
"date": "2026-07-04",
|
||||
"low": 72000,
|
||||
"mid": 73500,
|
||||
"high": 75000,
|
||||
"prev_mid": 73000,
|
||||
"pct": 0.68
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
توضیح فیلدها:
|
||||
|
||||
| فیلد | توضیح |
|
||||
|---|---|
|
||||
| `group` | گروه اصلی محصول |
|
||||
| `category` | دسته محصول |
|
||||
| `title` | نام کامل محصول |
|
||||
| `date` | تاریخ آخرین قیمت |
|
||||
| `low` | کمترین قیمت |
|
||||
| `mid` | قیمت میانی |
|
||||
| `high` | بیشترین قیمت |
|
||||
| `prev_mid` | قیمت میانی قبلی |
|
||||
| `pct` | درصد تغییر نسبت به قیمت قبلی |
|
||||
|
||||
## تاریخچه قیمت یک محصول
|
||||
|
||||
```http
|
||||
GET /api/v1/metals/history?title=PRODUCT_TITLE&days=365
|
||||
```
|
||||
|
||||
پارامترها:
|
||||
|
||||
| پارامتر | توضیح |
|
||||
|---|---|
|
||||
| `title` | نام دقیق محصول |
|
||||
| `days` | تعداد روزهای تاریخچه. اگر `0` باشد، همه تاریخچه برمی گردد |
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"date": "2026-07-01",
|
||||
"low": 71000,
|
||||
"mid": 72500,
|
||||
"high": 74000
|
||||
},
|
||||
{
|
||||
"date": "2026-07-02",
|
||||
"low": 72000,
|
||||
"mid": 73500,
|
||||
"high": 75000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## جستجوی محصول فلزی
|
||||
|
||||
```http
|
||||
GET /api/v1/metals/search?q=lithium
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "Lithium Carbonate 99.5%min China",
|
||||
"group": "Minor Metals",
|
||||
"category": "Lithium"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## TGJU: ارز، طلا و سکه
|
||||
|
||||
داده های TGJU در سه endpoint جدا ارائه می شوند:
|
||||
|
||||
```http
|
||||
GET /api/v1/currency
|
||||
GET /api/v1/gold
|
||||
GET /api/v1/coin
|
||||
```
|
||||
|
||||
همین داده ها داخل endpoint کلی هم وجود دارند:
|
||||
|
||||
```js
|
||||
data.datasets.currency
|
||||
data.datasets.gold
|
||||
data.datasets.coin
|
||||
```
|
||||
|
||||
نمونه پاسخ ارز:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "دلار",
|
||||
"price": 610000,
|
||||
"change_val": "1,200 (0.2%)",
|
||||
"pct": 0.2,
|
||||
"dir": "up",
|
||||
"fetched_at": "2026-07-04T09:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
نمونه پاسخ طلا:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "طلای 18 عیار",
|
||||
"price": 7200000,
|
||||
"change_val": "25,000 (0.35%)",
|
||||
"pct": 0.35,
|
||||
"dir": "up",
|
||||
"fetched_at": "2026-07-04T09:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
نمونه پاسخ سکه:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "سکه امامی",
|
||||
"price": 850000000,
|
||||
"change_val": "-1,500,000 (0.18%)",
|
||||
"pct": -0.18,
|
||||
"dir": "down",
|
||||
"fetched_at": "2026-07-04T09:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
توضیح فیلدهای TGJU:
|
||||
|
||||
| فیلد | توضیح |
|
||||
|---|---|
|
||||
| `name` | نام آیتم |
|
||||
| `price` | قیمت عددی |
|
||||
| `change_val` | متن تغییر قیمت طبق منبع |
|
||||
| `pct` | درصد تغییر |
|
||||
| `dir` | جهت تغییر: `up` یا `down` |
|
||||
| `fetched_at` | زمان دریافت داده |
|
||||
|
||||
## CoinGecko: کریپتو
|
||||
|
||||
```http
|
||||
GET /api/v1/crypto
|
||||
```
|
||||
|
||||
داخل endpoint کلی:
|
||||
|
||||
```js
|
||||
data.datasets.crypto
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
{
|
||||
"coins": [
|
||||
{
|
||||
"id": "bitcoin",
|
||||
"symbol": "BTC",
|
||||
"name": "Bitcoin",
|
||||
"image": "https://...",
|
||||
"rank": 1,
|
||||
"price": 65000,
|
||||
"pct_1h": 0.1,
|
||||
"pct_24h": 1.2,
|
||||
"pct_7d": -3.4,
|
||||
"market_cap": 1280000000000,
|
||||
"volume_24h": 32000000000,
|
||||
"high_24h": 66000,
|
||||
"low_24h": 64000,
|
||||
"circ_supply": 19600000,
|
||||
"ath": 69000,
|
||||
"sparkline": [64200, 64500, 65000],
|
||||
"dir": "up"
|
||||
}
|
||||
],
|
||||
"global": {
|
||||
"market_cap_usd": 2500000000000,
|
||||
"volume_usd": 90000000000,
|
||||
"btc_dominance": 51.2,
|
||||
"eth_dominance": 17.4,
|
||||
"market_cap_change_24h": 1.1,
|
||||
"active": 12000
|
||||
},
|
||||
"trending": [
|
||||
{
|
||||
"id": "example",
|
||||
"symbol": "EXM",
|
||||
"name": "Example Coin",
|
||||
"rank": 120,
|
||||
"thumb": "https://...",
|
||||
"price": 1.23,
|
||||
"pct_24h": 4.5
|
||||
}
|
||||
],
|
||||
"fetched_at": "2026-07-04T09:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## CompaniesMarketCap: سهام شرکت های فولادی
|
||||
|
||||
```http
|
||||
GET /api/v1/steel-stocks
|
||||
```
|
||||
|
||||
داخل endpoint کلی:
|
||||
|
||||
```js
|
||||
data.datasets.steel_stocks
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"code": "NUE",
|
||||
"rank": 1,
|
||||
"name": "Nucor",
|
||||
"market_cap": 38.5,
|
||||
"price": 156.2,
|
||||
"today_pct": 1.4,
|
||||
"dir": "up",
|
||||
"country": "United States",
|
||||
"fetched_at": "2026-07-04T09:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
توضیح:
|
||||
|
||||
| فیلد | توضیح |
|
||||
|---|---|
|
||||
| `code` | نماد شرکت |
|
||||
| `rank` | رتبه شرکت در لیست منبع |
|
||||
| `name` | نام شرکت |
|
||||
| `market_cap` | ارزش بازار، بر حسب میلیارد دلار |
|
||||
| `price` | قیمت سهم |
|
||||
| `today_pct` | درصد تغییر امروز |
|
||||
| `dir` | جهت تغییر |
|
||||
| `country` | کشور شرکت |
|
||||
| `fetched_at` | زمان دریافت داده |
|
||||
|
||||
## Yahoo Finance: کامودیتی های جهانی
|
||||
|
||||
```http
|
||||
GET /api/v1/commodity
|
||||
```
|
||||
|
||||
داخل endpoint کلی:
|
||||
|
||||
```js
|
||||
data.datasets.commodity
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"symbol": "GC=F",
|
||||
"name": "Gold",
|
||||
"price": 2350.5,
|
||||
"change": 8.2,
|
||||
"pct": 0.35,
|
||||
"currency": "USD",
|
||||
"up": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Trading Economics: اقتصاد جهان
|
||||
|
||||
```http
|
||||
GET /api/v1/world-economy
|
||||
```
|
||||
|
||||
داخل endpoint کلی:
|
||||
|
||||
```js
|
||||
data.datasets.world_economy
|
||||
```
|
||||
|
||||
نمونه پاسخ:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"country": "United States",
|
||||
"gdp": 27360,
|
||||
"gdp_growth": 1.4,
|
||||
"interest_rate": 5.5,
|
||||
"inflation_rate": 3.2,
|
||||
"jobless_rate": 4.0,
|
||||
"gov_budget": -6.3,
|
||||
"debt_gdp": 122.3,
|
||||
"current_account": -3.0,
|
||||
"population": 335,
|
||||
"fetched_at": "2026-07-04T09:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## کشف endpoint ها
|
||||
|
||||
برای گرفتن لیست endpoint های موجود:
|
||||
|
||||
```http
|
||||
GET /api/v1/datasets
|
||||
```
|
||||
|
||||
## نمونه کامل برای دوست شما
|
||||
|
||||
```js
|
||||
const API = "https://YOUR-DOMAIN.com";
|
||||
const TOKEN = "YOUR_API_TOKEN";
|
||||
|
||||
async function getJson(path) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const catalog = await getJson("/api/v1/metals/tree");
|
||||
const latestMetals = await getJson("/api/v1/metals/latest");
|
||||
const allPrices = await getJson("/api/v1/prices/latest");
|
||||
|
||||
console.log("Catalog:", catalog);
|
||||
console.log("Latest metals:", latestMetals);
|
||||
console.log("TGJU currency:", allPrices.datasets.currency);
|
||||
console.log("Crypto:", allPrices.datasets.crypto);
|
||||
```
|
||||
|
||||
## خلاصه دیتاست ها
|
||||
|
||||
| کلید در JSON | منبع | توضیح |
|
||||
|---|---|---|
|
||||
| `metals` | دیتابیس داخلی فلزات | 674 محصول فلزی و فولادی |
|
||||
| `currency` | TGJU | ارز |
|
||||
| `gold` | TGJU | طلا |
|
||||
| `coin` | TGJU | سکه |
|
||||
| `commodity` | Yahoo Finance | کامودیتی های جهانی مثل نفت، طلا، مس و غیره |
|
||||
| `steel_stocks` | CompaniesMarketCap | شرکت های فولادی برتر جهان |
|
||||
| `crypto` | CoinGecko | کریپتو، مارکت جهانی و ترندها |
|
||||
| `world_economy` | Trading Economics | شاخص های اقتصاد کلان کشورها |
|
||||
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
# Arvan VPS — Deploy Guide
|
||||
|
||||
Battle-tested steps for deploying to the Arvan cloud server. Written after a
|
||||
deploy where we hit every trap below — follow this and they won't bite again.
|
||||
|
||||
## Server facts
|
||||
|
||||
| Thing | Value |
|
||||
|---|---|
|
||||
| SSH host (inbound) | `94.101.187.12` |
|
||||
| **Egress IP** (outbound — for API whitelists, e.g. Asian Metal) | **`31.171.101.234`** ⚠️ different from SSH IP |
|
||||
| SSH user | `root` **only** (key auth) — `ubuntu` does NOT work |
|
||||
| SSH key | `~/.ssh/yz_deploy_ed25519` (public key is in `root`'s `authorized_keys`) |
|
||||
| Code on server | `/root/statista` — **plain copied tree, NOT a git repo** |
|
||||
| Live config | `/root/statista/deploy/.env` (root-only, **untracked — never in git**) |
|
||||
| Stack | `docker compose -f deploy/docker-compose.yml` → `nginx` + `frontend` (:3000) + `backend` (:8000) |
|
||||
| Public URL | `http://94.101.187.12` (nginx on :80) |
|
||||
|
||||
## The deploy (copy-paste, run from your local repo)
|
||||
|
||||
```bash
|
||||
KEY=~/.ssh/yz_deploy_ed25519
|
||||
BR=deploy-vps-restructure # branch to deploy
|
||||
|
||||
# 1) ship the committed tree (NOT a git pull — server isn't a git repo).
|
||||
# git archive only includes tracked files, so deploy/.env + data are untouched.
|
||||
git archive --format=tar "$BR" | \
|
||||
ssh -i "$KEY" root@94.101.187.12 'cd /root/statista && tar -xf -'
|
||||
|
||||
# 2) rebuild images + recreate containers
|
||||
ssh -i "$KEY" root@94.101.187.12 \
|
||||
'cd /root/statista && docker compose -f deploy/docker-compose.yml up -d --build'
|
||||
|
||||
# 3) ⚠️ ALWAYS restart nginx afterwards (see Trap #2)
|
||||
ssh -i "$KEY" root@94.101.187.12 \
|
||||
'cd /root/statista && docker compose -f deploy/docker-compose.yml restart nginx'
|
||||
|
||||
# 4) verify (all must be 200)
|
||||
ssh -i "$KEY" root@94.101.187.12 \
|
||||
'for p in / /manifest.webmanifest /sw.js /api/currency; do
|
||||
echo "$p -> $(curl -s -o /dev/null -w "%{http_code}" http://localhost$p)"; done'
|
||||
```
|
||||
|
||||
Commit + push first (`git push origin deploy-vps-restructure`) if you want the
|
||||
GitHub copy current — but the archive deploys your **local** committed tree
|
||||
regardless, so step 1 is the source of truth for the server.
|
||||
|
||||
## Traps we hit (and the fixes)
|
||||
|
||||
### Trap #1 — Server is NOT a git repo
|
||||
`/root/statista` was copied (rsync/scp), not cloned. `git pull` fails with
|
||||
"not a git repository". **Fix:** deploy with `git archive | ssh … tar -xf -`
|
||||
(above). It overwrites only tracked files; untracked `deploy/.env`, the SQLite
|
||||
DB, and `/data` reports are left alone.
|
||||
|
||||
### Trap #2 — nginx 502 after rebuild (stale upstream IP)
|
||||
`up -d --build` recreates `frontend`/`backend` with **new Docker IPs**, but
|
||||
nginx keeps running and caches the **old** IPs → every route returns **502**
|
||||
even though the containers are healthy and listening. **Fix:** always
|
||||
`docker compose restart nginx` as the last step. (Confirmed: frontend logs show
|
||||
`Listening on :3000` while nginx still 502s — that's this, not an app bug.)
|
||||
|
||||
### Trap #3 — SSH access
|
||||
- Only **`root` + key** works. `ubuntu` is rejected.
|
||||
- **Password login is unreliable via Arvan's web console** — the noVNC keyboard
|
||||
layout mistypes mixed-case/symbol passwords, so a *correct* password reads as
|
||||
"wrong". Always SSH **from a real terminal**, not the web console. If you must
|
||||
reset the password (panel) use **all-lowercase + digits** and **reboot** after
|
||||
(resets often don't apply until restart).
|
||||
- To grant a new machine access: append its public key to
|
||||
`/root/.ssh/authorized_keys` (don't rebuild the server to inject keys).
|
||||
|
||||
### Trap #4 — never "Rebuild / reinstall OS"
|
||||
Rebuild/reinstall **wipes the disk** (DB + reports gone) and can **change the
|
||||
IP** (breaks API whitelists). Updates go through the deploy steps above —
|
||||
**only ever `reboot`**, never rebuild. Reboots keep IP + data.
|
||||
|
||||
### Trap #5 — egress ≠ inbound IP
|
||||
APIs that whitelist by IP (Asian Metal: "1 API = 1 IP") see the **egress IP
|
||||
`31.171.101.234`**, not the SSH IP. Verify any time with:
|
||||
`ssh -i $KEY root@94.101.187.12 'curl -s https://api.ipify.org'`
|
||||
|
||||
## Rollback
|
||||
`up -d --build` keeps the previous images. If a deploy is bad:
|
||||
```bash
|
||||
ssh -i $KEY root@94.101.187.12 \
|
||||
'cd /root/statista && docker compose -f deploy/docker-compose.yml down && \
|
||||
docker compose -f deploy/docker-compose.yml up -d && \
|
||||
docker compose -f deploy/docker-compose.yml restart nginx'
|
||||
```
|
||||
For a code rollback, `git archive` an earlier commit/tag in step 1, then rebuild.
|
||||
|
||||
## Health check anytime
|
||||
```bash
|
||||
ssh -i $KEY root@94.101.187.12 'docker compose -f /root/statista/deploy/docker-compose.yml ps'
|
||||
ssh -i $KEY root@94.101.187.12 'docker logs deploy-frontend-1 --tail 20'
|
||||
ssh -i $KEY root@94.101.187.12 'docker logs deploy-backend-1 --tail 20'
|
||||
```
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<div dir="rtl">
|
||||
|
||||
# راهنمای دیپلوی روی Liara
|
||||
|
||||
این پروژه دو سرویسِ مستقل دارد که جدا دیپلوی میشوند:
|
||||
|
||||
1. **بکاند (API + اسکرپرها)** → پلتفرم **Docker** لیارا (بهخاطر Playwright/Chromium) + **دیسکِ پایدار** برای SQLite و گزارشها.
|
||||
2. **فرانتاند (TanStack Start)** → پلتفرم **Node** لیارا، یا کلودفلر (تارگتِ پیشفرضِ بیلد).
|
||||
3. **پنل ادمین (`:8001`)** → عمومی نکن؛ فقط لوکال/SSH/VPN.
|
||||
|
||||
> قبل از شروع: `npm i -g @liara/cli` و `liara login`.
|
||||
|
||||
---
|
||||
|
||||
## گام ۱ — بکاند روی Liara (Docker)
|
||||
|
||||
آرتیفکتها از قبل ساخته شدهاند: `backend/Dockerfile`، `backend/requirements.txt`، `backend/liara.json`.
|
||||
|
||||
```powershell
|
||||
# ۱) ساخت برنامه (یکبار) — پلتفرم docker
|
||||
liara app:create --name didvan-api --platform docker
|
||||
|
||||
# ۲) ساخت دیسکِ پایدار برای دیتابیس + گزارشها (روی /data مونت میشود)
|
||||
liara disk:create --app didvan-api --name data --size 5 # گیگابایت دلخواه
|
||||
|
||||
# ۳) تنظیم متغیرهای محیطی (حتماً قبل از دیپلوی)
|
||||
liara env:set --app didvan-api `
|
||||
DB_PATH=/data/asianmetal.db `
|
||||
REPORTS_DIR=/data/reports `
|
||||
ADMIN_PASSWORD=یکرمزِخیلیقوی `
|
||||
ASIANMETAL_TOKEN=توکنِتازهٔمنبع `
|
||||
ALLOWED_ORIGINS=https://didvan.example.com `
|
||||
COOKIE_SECURE=1 `
|
||||
COOKIE_SAMESITE=none
|
||||
|
||||
# ۴) دیپلوی از داخل پوشهٔ backend
|
||||
cd backend
|
||||
liara deploy --app didvan-api --port 8000
|
||||
```
|
||||
|
||||
نکتهها:
|
||||
- `liara.json` داخلِ `backend/` پلتفرم/پورت/دیسک را تعریف کرده؛ پس `liara deploy` را از همان پوشه بزن.
|
||||
- Chromium در خودِ image نصب میشود (`playwright install --with-deps chromium`) → بیلدِ اول کند است. پلنِ حداقل **۱GB RAM**.
|
||||
- بعد از دیپلوی، آدرسِ بکاند مثلاً میشود `https://didvan-api.liara.run`.
|
||||
|
||||
### آپلودِ آرشیو گزارشها (PDFها)
|
||||
گزارشها (~۵۰۰۰ فایل) باید روی دیسک باشند. بعد از اولین دیپلوی، از پنل لیارا (بخش دیسک/فایلمنیجر) یا با ابزارِ لیارا فایلها را در `/data/reports` بریز، با همان ساختار `<منبع> - <دسته>/<file>.pdf`. اگر حجم زیاد است، گزینهٔ بهترْ **آبجکتاستوریجِ S3ای لیارا** است (نیازمندِ تغییر `reports.py` برای خواندن از S3).
|
||||
|
||||
---
|
||||
|
||||
## گام ۲ — فرانتاند
|
||||
|
||||
فرانت با URLِ بکاند کار میکند؛ متغیرِ بیلد را ست کن:
|
||||
|
||||
```
|
||||
VITE_API_BASE=https://didvan-api.liara.run
|
||||
```
|
||||
|
||||
(در حالت dev این خالی میماند و از پراکسیِ Vite استفاده میشود — چیزی خراب نمیشود.)
|
||||
|
||||
پروژه برای لیارا تنظیم شده تا خروجیِ **سرورِ Node** بدهد (در `vite.config.ts` ⇠ `nitro: { preset: "node-server" }`). بیلد، `.output/server/index.mjs` میسازد و اسکریپتِ `start` (در `package.json`) آن را اجرا میکند. کانفیگ در `liara.json`ِ ریشه (پلتفرم node، پورت 3000) است.
|
||||
|
||||
```powershell
|
||||
# از ریشهٔ پروژه
|
||||
liara app:create --name didvan --platform node
|
||||
liara env:set --app didvan VITE_API_BASE=https://didvan-api.liara.run
|
||||
liara deploy --app didvan
|
||||
```
|
||||
|
||||
لیارا هنگام دیپلوی `npm install` → `npm run build` → `npm start` (یعنی `node .output/server/index.mjs`) را اجرا میکند؛ سرورِ Nitro روی متغیرِ `PORT` که لیارا میدهد گوش میدهد. (تستشده: سرور بوت میشود و صفحات را سرو میکند.)
|
||||
|
||||
---
|
||||
|
||||
## گام ۳ — کوکیِ ورود (مهم)
|
||||
|
||||
ورود با **کوکیِ نشستِ httpOnly** کار میکند. بسته به اینکه فرانت و بکاند همدامنهاند یا نه:
|
||||
|
||||
| حالت | تنظیمات بکاند |
|
||||
|---|---|
|
||||
| **همدامنه** (توصیهشده) | `COOKIE_SAMESITE=lax` ، `COOKIE_SECURE=1` |
|
||||
| **دامنهٔ جدا** (فرانت و API فرق دارند) | `COOKIE_SAMESITE=none` ، `COOKIE_SECURE=1` ، و `ALLOWED_ORIGINS=https://<دامنهٔ فرانت>` |
|
||||
|
||||
فرانت همهٔ درخواستهای احراز را با `credentials:"include"` میزند (از قبل انجام شده). CORS هم با `allow_credentials=True` تنظیم شده.
|
||||
|
||||
---
|
||||
|
||||
## چکلیستِ قبل از انتشار
|
||||
|
||||
- [ ] توکنِ قدیمیِ منبعِ داده را **باطل** کن (در git history لو رفته) و مقدارِ تازه را در `ASIANMETAL_TOKEN` بگذار.
|
||||
- [ ] `ADMIN_PASSWORD` قوی (بدونش پنل ادمین بالا نمیآید).
|
||||
- [ ] `COOKIE_SECURE=1` روی HTTPS.
|
||||
- [ ] `ALLOWED_ORIGINS` دقیقاً دامنهٔ فرانت (نه `*`).
|
||||
- [ ] `DB_PATH` و `REPORTS_DIR` روی دیسکِ `/data`.
|
||||
- [ ] پنل ادمین (`:8001`) عمومی نشود.
|
||||
- [ ] `__pycache__` از گیت پاک شود: `git rm -r --cached backend/__pycache__`.
|
||||
- [ ] (اختیاری ولی بهتر) مهاجرت SQLite → **PostgreSQLِ مدیریتشدهٔ لیارا** برای پایداری/مقیاس.
|
||||
|
||||
---
|
||||
|
||||
## یادآوریِ معماری
|
||||
- اسکرپرها داخلِ پروسهٔ بکاند (FastAPI lifespan) اجرا میشوند؛ تا پروسه بالا باشد کار میکنند. ریاستارت، کشِ حافظه را پاک میکند ولی از DB دوباره seed میشود.
|
||||
- جزئیات کامل سیستم در `README.md`.
|
||||
|
||||
</div>
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
{
|
||||
"summary": {
|
||||
"metals": 0,
|
||||
"live_commodities": 0,
|
||||
"tgju_currency": 33,
|
||||
"tgju_gold": 14,
|
||||
"tgju_coin": 5,
|
||||
"yahoo_finance_commodities": 19,
|
||||
"companies_market_cap_steel_stocks": 81,
|
||||
"coingecko_crypto": 100,
|
||||
"coingecko_trending": 15,
|
||||
"world_economy": 0
|
||||
},
|
||||
"generated_at": "2026-07-04T10:52:23Z",
|
||||
"metals": [],
|
||||
"live_commodities": [],
|
||||
"tgju_currency": [
|
||||
"دلار / نقدی",
|
||||
"یورو",
|
||||
"درهم امارات",
|
||||
"پوند انگلیس",
|
||||
"لیر ترکیه",
|
||||
"فرانک سوئیس",
|
||||
"یوان چین",
|
||||
"ین ژاپن (100 ین)",
|
||||
"وون کره جنوبی",
|
||||
"دلار کانادا",
|
||||
"دلار استرالیا",
|
||||
"دلار نیوزیلند",
|
||||
"دلار سنگاپور",
|
||||
"روپیه هند",
|
||||
"روپیه پاکستان",
|
||||
"دینار عراق",
|
||||
"لیر سوریه",
|
||||
"افغانی",
|
||||
"کرون دانمارک",
|
||||
"کرون سوئد",
|
||||
"کرون نروژ",
|
||||
"ریال عربستان",
|
||||
"ریال قطر",
|
||||
"ریال عمان",
|
||||
"دینار کویت",
|
||||
"دینار بحرین",
|
||||
"رینگیت مالزی",
|
||||
"بات تایلند",
|
||||
"دلار هنگ کنگ",
|
||||
"روبل روسیه",
|
||||
"منات آذربایجان",
|
||||
"درام ارمنستان",
|
||||
"لاری گرجستان"
|
||||
],
|
||||
"tgju_gold": [
|
||||
"طلای 18 عیار / 750",
|
||||
"طلای 18 عیار / 740",
|
||||
"طلای ۲۴ عیار",
|
||||
"طلای دست دوم",
|
||||
"مثقال طلا",
|
||||
"master.mithqal_world_class",
|
||||
"مثقال / حواله دلار",
|
||||
"مثقال / بر مبنای سکه",
|
||||
"آبشده نقدی",
|
||||
"آبشده معاملاتی",
|
||||
"آبشده بنکداری",
|
||||
"آبشده کمتر از کیلو",
|
||||
"گرم نقره 925",
|
||||
"گرم نقره 999"
|
||||
],
|
||||
"tgju_coin": [
|
||||
"سکه امامی",
|
||||
"سکه بهار آزادی",
|
||||
"نیم سکه",
|
||||
"ربع سکه",
|
||||
"سکه گرمی"
|
||||
],
|
||||
"yahoo_finance_commodities": [
|
||||
"Crude Oil WTI",
|
||||
"Brent Crude",
|
||||
"Natural Gas",
|
||||
"Gasoline",
|
||||
"Heating Oil",
|
||||
"Gold",
|
||||
"Silver",
|
||||
"Copper",
|
||||
"Platinum",
|
||||
"Palladium",
|
||||
"Aluminum",
|
||||
"Corn",
|
||||
"Wheat",
|
||||
"Soybeans",
|
||||
"Coffee",
|
||||
"Cotton",
|
||||
"Cocoa",
|
||||
"Sugar",
|
||||
"Orange Juice"
|
||||
],
|
||||
"companies_market_cap_steel_stocks": [
|
||||
"Nucor",
|
||||
"ArcelorMittal",
|
||||
"Steel Dynamics",
|
||||
"JSW Steel",
|
||||
"Carpenter Technology",
|
||||
"Tenaris",
|
||||
"Tata Steel",
|
||||
"Reliance",
|
||||
"China Steel",
|
||||
"Baosteel",
|
||||
"Nippon Steel",
|
||||
"POSCO",
|
||||
"Jindal Steel & Power",
|
||||
"Citic Pacific Special Steel Group",
|
||||
"SSAB",
|
||||
"BlueScope Steel",
|
||||
"Steel Authority of India",
|
||||
"Commercial Metals Company",
|
||||
"JFE Holdings",
|
||||
"Severstal",
|
||||
"Vallourec",
|
||||
"Aperam",
|
||||
"Salzgitter",
|
||||
"Outokumpu",
|
||||
"Maruichi Steel Tube",
|
||||
"Alleima AB",
|
||||
"Usiminas",
|
||||
"Ternium Argentina",
|
||||
"Worthington Steel",
|
||||
"Klöckner & Co",
|
||||
"Labrador Iron Ore Royalty Corp.",
|
||||
"Yodogawa Steel Works",
|
||||
"Ramkrishna Forgings",
|
||||
"Vesuvius India",
|
||||
"Mishra Dhatu Nigam",
|
||||
"Kirloskar Ferrous Industries",
|
||||
"Metallus",
|
||||
"Saudi Steel Pipes",
|
||||
"Insteel Industries",
|
||||
"Al-Yamamah Steel Industries Company",
|
||||
"Olympic Steel",
|
||||
"Kyoei Steel",
|
||||
"Osaka Steel",
|
||||
"Chubu Steel Plate",
|
||||
"Stalprodukt",
|
||||
"Eisen- und Hüttenwerke",
|
||||
"Al Jazeera Steel Products",
|
||||
"Prakash Industries",
|
||||
"National Metal Manufacturing and Casting",
|
||||
"ZK International Group",
|
||||
"Swiss Steel",
|
||||
"Steel & Tube Holdings",
|
||||
"Tubos Reunidos",
|
||||
"Zwahlen & Mayr SA",
|
||||
"Erdemir",
|
||||
"Viohalco",
|
||||
"Kobe Steel",
|
||||
"Novolipetsk Steel",
|
||||
"Daido Steel",
|
||||
"İsdemir",
|
||||
"Gallantt Ispat",
|
||||
"Godawari Power & Ispat",
|
||||
"Aichi Steel",
|
||||
"Companhia Siderúrgica Nacional",
|
||||
"Kalyani Steels",
|
||||
"Algoma Steel",
|
||||
"ADF Group",
|
||||
"Arabian Pipes Company",
|
||||
"MM Forgings",
|
||||
"Friedman Industries",
|
||||
"Bisalloy Steel Group",
|
||||
"Severfield",
|
||||
"Voestalpine",
|
||||
"Thyssenkrupp",
|
||||
"Ternium",
|
||||
"Gerdau",
|
||||
"Cleveland-Cliffs",
|
||||
"Posco International",
|
||||
"Acerinox",
|
||||
"Grupo Simec",
|
||||
"Danieli & C. Officine Meccaniche"
|
||||
],
|
||||
"coingecko_crypto": [
|
||||
"Bitcoin",
|
||||
"Ethereum",
|
||||
"Tether",
|
||||
"BNB",
|
||||
"USDC",
|
||||
"XRP",
|
||||
"Solana",
|
||||
"TRON",
|
||||
"Figure Heloc",
|
||||
"Hyperliquid",
|
||||
"Dogecoin",
|
||||
"Rain",
|
||||
"USDS",
|
||||
"LEO Token",
|
||||
"Zcash",
|
||||
"Stellar",
|
||||
"WhiteBIT Coin",
|
||||
"Cardano",
|
||||
"Monero",
|
||||
"Chainlink",
|
||||
"Canton",
|
||||
"Gram (prev. Toncoin)",
|
||||
"Dai",
|
||||
"USD1",
|
||||
"Bitcoin Cash",
|
||||
"Ethena USDe",
|
||||
"Litecoin",
|
||||
"LAB",
|
||||
"Hedera",
|
||||
"Circle USYC",
|
||||
"Sui",
|
||||
"Global Dollar",
|
||||
"Avalanche",
|
||||
"PayPal USD",
|
||||
"Cronos",
|
||||
"Shiba Inu",
|
||||
"Tether Gold",
|
||||
"NEAR Protocol",
|
||||
"BlackRock USD Institutional Digital Liquidity Fund",
|
||||
"Ondo US Dollar Yield",
|
||||
"MemeCore",
|
||||
"Bittensor",
|
||||
"Uniswap",
|
||||
"PAX Gold",
|
||||
"World Liberty Financial",
|
||||
"Aster",
|
||||
"OKB",
|
||||
"Ondo",
|
||||
"Ripple USD",
|
||||
"HTX DAO",
|
||||
"Worldcoin",
|
||||
"Polkadot",
|
||||
"Mantle",
|
||||
"Falcon USD",
|
||||
"USDD",
|
||||
"Sky",
|
||||
"Aave",
|
||||
"BFUSD",
|
||||
"Pi Network",
|
||||
"Morpho",
|
||||
"Internet Computer",
|
||||
"Bitget Token",
|
||||
"Ethereum Classic",
|
||||
"Pepe",
|
||||
"DeXe",
|
||||
"United Stables",
|
||||
"Spiko EU T-Bills Money Market Fund",
|
||||
"Quant",
|
||||
"Blockchain Capital",
|
||||
"KuCoin",
|
||||
"Audiera",
|
||||
"USDGO",
|
||||
"Janus Henderson Anemoy Treasury Fund",
|
||||
"Invesco Short Duration US Government Securities Fund",
|
||||
"Kaspa",
|
||||
"Stable",
|
||||
"Render",
|
||||
"Cosmos Hub",
|
||||
"Algorand",
|
||||
"POL (ex-MATIC)",
|
||||
"Jupiter",
|
||||
"JUST",
|
||||
"NEXO",
|
||||
"USDtb",
|
||||
"ADI",
|
||||
"Gate",
|
||||
"Ethena",
|
||||
"Beldex",
|
||||
"币安人生 (BinanceLife)",
|
||||
"Spiko Amundi Overnight Swap Fund (EUR)",
|
||||
"Janus Henderson Anemoy AAA CLO Fund",
|
||||
"Pump.fun",
|
||||
"Filecoin",
|
||||
"Flare",
|
||||
"GHO",
|
||||
"Venice Token",
|
||||
"XDC Network",
|
||||
"Usual USD",
|
||||
"YLDS",
|
||||
"Lighter"
|
||||
],
|
||||
"coingecko_trending": [
|
||||
"The Black Bull",
|
||||
"Pudgy Penguins",
|
||||
"Hamster Kombat",
|
||||
"Alien Worlds",
|
||||
"Solana",
|
||||
"LAB",
|
||||
"Solstice",
|
||||
"Moonbeam",
|
||||
"Lighter",
|
||||
"Hyperliquid",
|
||||
"Pump.fun",
|
||||
"manlet",
|
||||
"Siren",
|
||||
"Bitcoin",
|
||||
"Gram (prev. Toncoin)"
|
||||
],
|
||||
"world_economy": []
|
||||
}
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
# Live API Data Catalog
|
||||
|
||||
Generated at: 2026-07-04T10:52:23Z
|
||||
|
||||
## Summary
|
||||
|
||||
- metals: 0
|
||||
- live_commodities: 0
|
||||
- tgju_currency: 33
|
||||
- tgju_gold: 14
|
||||
- tgju_coin: 5
|
||||
- yahoo_finance_commodities: 19
|
||||
- companies_market_cap_steel_stocks: 81
|
||||
- coingecko_crypto: 100
|
||||
- coingecko_trending: 15
|
||||
- world_economy: 0
|
||||
|
||||
## metals (0)
|
||||
|
||||
|
||||
## live_commodities (0)
|
||||
|
||||
|
||||
## tgju_currency (33)
|
||||
|
||||
- دلار / نقدی
|
||||
- یورو
|
||||
- درهم امارات
|
||||
- پوند انگلیس
|
||||
- لیر ترکیه
|
||||
- فرانک سوئیس
|
||||
- یوان چین
|
||||
- ین ژاپن (100 ین)
|
||||
- وون کره جنوبی
|
||||
- دلار کانادا
|
||||
- دلار استرالیا
|
||||
- دلار نیوزیلند
|
||||
- دلار سنگاپور
|
||||
- روپیه هند
|
||||
- روپیه پاکستان
|
||||
- دینار عراق
|
||||
- لیر سوریه
|
||||
- افغانی
|
||||
- کرون دانمارک
|
||||
- کرون سوئد
|
||||
- کرون نروژ
|
||||
- ریال عربستان
|
||||
- ریال قطر
|
||||
- ریال عمان
|
||||
- دینار کویت
|
||||
- دینار بحرین
|
||||
- رینگیت مالزی
|
||||
- بات تایلند
|
||||
- دلار هنگ کنگ
|
||||
- روبل روسیه
|
||||
- منات آذربایجان
|
||||
- درام ارمنستان
|
||||
- لاری گرجستان
|
||||
|
||||
## tgju_gold (14)
|
||||
|
||||
- طلای 18 عیار / 750
|
||||
- طلای 18 عیار / 740
|
||||
- طلای ۲۴ عیار
|
||||
- طلای دست دوم
|
||||
- مثقال طلا
|
||||
- master.mithqal_world_class
|
||||
- مثقال / حواله دلار
|
||||
- مثقال / بر مبنای سکه
|
||||
- آبشده نقدی
|
||||
- آبشده معاملاتی
|
||||
- آبشده بنکداری
|
||||
- آبشده کمتر از کیلو
|
||||
- گرم نقره 925
|
||||
- گرم نقره 999
|
||||
|
||||
## tgju_coin (5)
|
||||
|
||||
- سکه امامی
|
||||
- سکه بهار آزادی
|
||||
- نیم سکه
|
||||
- ربع سکه
|
||||
- سکه گرمی
|
||||
|
||||
## yahoo_finance_commodities (19)
|
||||
|
||||
- Crude Oil WTI
|
||||
- Brent Crude
|
||||
- Natural Gas
|
||||
- Gasoline
|
||||
- Heating Oil
|
||||
- Gold
|
||||
- Silver
|
||||
- Copper
|
||||
- Platinum
|
||||
- Palladium
|
||||
- Aluminum
|
||||
- Corn
|
||||
- Wheat
|
||||
- Soybeans
|
||||
- Coffee
|
||||
- Cotton
|
||||
- Cocoa
|
||||
- Sugar
|
||||
- Orange Juice
|
||||
|
||||
## companies_market_cap_steel_stocks (81)
|
||||
|
||||
- Nucor
|
||||
- ArcelorMittal
|
||||
- Steel Dynamics
|
||||
- JSW Steel
|
||||
- Carpenter Technology
|
||||
- Tenaris
|
||||
- Tata Steel
|
||||
- Reliance
|
||||
- China Steel
|
||||
- Baosteel
|
||||
- Nippon Steel
|
||||
- POSCO
|
||||
- Jindal Steel & Power
|
||||
- Citic Pacific Special Steel Group
|
||||
- SSAB
|
||||
- BlueScope Steel
|
||||
- Steel Authority of India
|
||||
- Commercial Metals Company
|
||||
- JFE Holdings
|
||||
- Severstal
|
||||
- Vallourec
|
||||
- Aperam
|
||||
- Salzgitter
|
||||
- Outokumpu
|
||||
- Maruichi Steel Tube
|
||||
- Alleima AB
|
||||
- Usiminas
|
||||
- Ternium Argentina
|
||||
- Worthington Steel
|
||||
- Klöckner & Co
|
||||
- Labrador Iron Ore Royalty Corp.
|
||||
- Yodogawa Steel Works
|
||||
- Ramkrishna Forgings
|
||||
- Vesuvius India
|
||||
- Mishra Dhatu Nigam
|
||||
- Kirloskar Ferrous Industries
|
||||
- Metallus
|
||||
- Saudi Steel Pipes
|
||||
- Insteel Industries
|
||||
- Al-Yamamah Steel Industries Company
|
||||
- Olympic Steel
|
||||
- Kyoei Steel
|
||||
- Osaka Steel
|
||||
- Chubu Steel Plate
|
||||
- Stalprodukt
|
||||
- Eisen- und Hüttenwerke
|
||||
- Al Jazeera Steel Products
|
||||
- Prakash Industries
|
||||
- National Metal Manufacturing and Casting
|
||||
- ZK International Group
|
||||
- Swiss Steel
|
||||
- Steel & Tube Holdings
|
||||
- Tubos Reunidos
|
||||
- Zwahlen & Mayr SA
|
||||
- Erdemir
|
||||
- Viohalco
|
||||
- Kobe Steel
|
||||
- Novolipetsk Steel
|
||||
- Daido Steel
|
||||
- İsdemir
|
||||
- Gallantt Ispat
|
||||
- Godawari Power & Ispat
|
||||
- Aichi Steel
|
||||
- Companhia Siderúrgica Nacional
|
||||
- Kalyani Steels
|
||||
- Algoma Steel
|
||||
- ADF Group
|
||||
- Arabian Pipes Company
|
||||
- MM Forgings
|
||||
- Friedman Industries
|
||||
- Bisalloy Steel Group
|
||||
- Severfield
|
||||
- Voestalpine
|
||||
- Thyssenkrupp
|
||||
- Ternium
|
||||
- Gerdau
|
||||
- Cleveland-Cliffs
|
||||
- Posco International
|
||||
- Acerinox
|
||||
- Grupo Simec
|
||||
- Danieli & C. Officine Meccaniche
|
||||
|
||||
## coingecko_crypto (100)
|
||||
|
||||
- Bitcoin
|
||||
- Ethereum
|
||||
- Tether
|
||||
- BNB
|
||||
- USDC
|
||||
- XRP
|
||||
- Solana
|
||||
- TRON
|
||||
- Figure Heloc
|
||||
- Hyperliquid
|
||||
- Dogecoin
|
||||
- Rain
|
||||
- USDS
|
||||
- LEO Token
|
||||
- Zcash
|
||||
- Stellar
|
||||
- WhiteBIT Coin
|
||||
- Cardano
|
||||
- Monero
|
||||
- Chainlink
|
||||
- Canton
|
||||
- Gram (prev. Toncoin)
|
||||
- Dai
|
||||
- USD1
|
||||
- Bitcoin Cash
|
||||
- Ethena USDe
|
||||
- Litecoin
|
||||
- LAB
|
||||
- Hedera
|
||||
- Circle USYC
|
||||
- Sui
|
||||
- Global Dollar
|
||||
- Avalanche
|
||||
- PayPal USD
|
||||
- Cronos
|
||||
- Shiba Inu
|
||||
- Tether Gold
|
||||
- NEAR Protocol
|
||||
- BlackRock USD Institutional Digital Liquidity Fund
|
||||
- Ondo US Dollar Yield
|
||||
- MemeCore
|
||||
- Bittensor
|
||||
- Uniswap
|
||||
- PAX Gold
|
||||
- World Liberty Financial
|
||||
- Aster
|
||||
- OKB
|
||||
- Ondo
|
||||
- Ripple USD
|
||||
- HTX DAO
|
||||
- Worldcoin
|
||||
- Polkadot
|
||||
- Mantle
|
||||
- Falcon USD
|
||||
- USDD
|
||||
- Sky
|
||||
- Aave
|
||||
- BFUSD
|
||||
- Pi Network
|
||||
- Morpho
|
||||
- Internet Computer
|
||||
- Bitget Token
|
||||
- Ethereum Classic
|
||||
- Pepe
|
||||
- DeXe
|
||||
- United Stables
|
||||
- Spiko EU T-Bills Money Market Fund
|
||||
- Quant
|
||||
- Blockchain Capital
|
||||
- KuCoin
|
||||
- Audiera
|
||||
- USDGO
|
||||
- Janus Henderson Anemoy Treasury Fund
|
||||
- Invesco Short Duration US Government Securities Fund
|
||||
- Kaspa
|
||||
- Stable
|
||||
- Render
|
||||
- Cosmos Hub
|
||||
- Algorand
|
||||
- POL (ex-MATIC)
|
||||
- Jupiter
|
||||
- JUST
|
||||
- NEXO
|
||||
- USDtb
|
||||
- ADI
|
||||
- Gate
|
||||
- Ethena
|
||||
- Beldex
|
||||
- 币安人生 (BinanceLife)
|
||||
- Spiko Amundi Overnight Swap Fund (EUR)
|
||||
- Janus Henderson Anemoy AAA CLO Fund
|
||||
- Pump.fun
|
||||
- Filecoin
|
||||
- Flare
|
||||
- GHO
|
||||
- Venice Token
|
||||
- XDC Network
|
||||
- Usual USD
|
||||
- YLDS
|
||||
- Lighter
|
||||
|
||||
## coingecko_trending (15)
|
||||
|
||||
- The Black Bull
|
||||
- Pudgy Penguins
|
||||
- Hamster Kombat
|
||||
- Alien Worlds
|
||||
- Solana
|
||||
- LAB
|
||||
- Solstice
|
||||
- Moonbeam
|
||||
- Lighter
|
||||
- Hyperliquid
|
||||
- Pump.fun
|
||||
- manlet
|
||||
- Siren
|
||||
- Bitcoin
|
||||
- Gram (prev. Toncoin)
|
||||
|
||||
## world_economy (0)
|
||||
|
||||
|
|
@ -0,0 +1,687 @@
|
|||
group,category,title
|
||||
Base Metals,Copper,Blister Copper TC 98.5%min Delivered China RMB/mt
|
||||
Base Metals,Copper,Copper Cathode 99.99%min Delivered China RMB/mt
|
||||
Base Metals,Copper,Copper Conc. 20%min Delivered China RMB/mt Cu
|
||||
Base Metals,Copper,Copper Conc. TC 25%min CIF China USD/mt
|
||||
Base Metals,Copper,Copper Rod TC ?8mm EXW China RMB/mt
|
||||
Base Metals,Copper,Copper Scrap Wire 99%min Delivered China RMB/mt
|
||||
Base Metals,Lead,Lead Conc. 60%min EXW China RMB/mt Pb
|
||||
Base Metals,Lead,Lead Conc. TC 50%min CIF China USD/mt
|
||||
Base Metals,Lead,Lead Ingot 99.994%min In warehouse Guangzhou RMB/mt
|
||||
Base Metals,Lead,Lead Ingot 99.994%min In warehouse Shanghai RMB/mt
|
||||
Base Metals,Lead,Lead Ingot Secondary 98%min EXW China RMB/mt
|
||||
Base Metals,Lead,Lead Ingot Secondary 98%min Ex-VAT EXW China RMB/mt
|
||||
Base Metals,Lead,Lead Ingot Secondary 99.994%min EXW China RMB/mt
|
||||
Base Metals,Lead,Lead Ingot Secondary 99.994%min Ex-VAT EXW China RMB/mt
|
||||
Base Metals,Lead,Lead Scrap Recycled Deep Cycle Battery Ex-VAT Delivered China RMB/mt
|
||||
Base Metals,Lead,Lead Scrap Recycled Start-type Battery Ex-VAT Delivered China RMB/mt
|
||||
Base Metals,Nickel,Nickel Cathode 99.96%min EXW China RMB/mt
|
||||
Base Metals,Nickel,Nickel Cathode Norilsk 99.96%min In port China RMB/mt
|
||||
Base Metals,Nickel,Nickel Ore Philippine 0.8%min CIF China USD/mt
|
||||
Base Metals,Nickel,Nickel Ore Philippine 0.8%min In port China RMB/mt
|
||||
Base Metals,Nickel,Nickel Ore Philippine 1.3%min CIF China USD/mt
|
||||
Base Metals,Nickel,Nickel Ore Philippine 1.5%min CIF China USD/mt
|
||||
Base Metals,Nickel,Nickel Ore Philippine 1.5%min In port China RMB/mt
|
||||
Base Metals,Nickel,Nickel Ore Philippine 1.8%min CIF China USD/mt
|
||||
Base Metals,Nickel,Nickel Sulfate Ni 22%min; Co 0.05%max Delivered China RMB/mt
|
||||
Base Metals,Nickel,Nickel Sulfate Ni 22%min; Co 0.4%max Delivered China RMB/mt
|
||||
Base Metals,Tin,Tin Conc. 60%min Delivered China RMB/mtm
|
||||
Base Metals,Tin,Tin Conc. Burmese 20%min In warehouse MengA RMB/mtm
|
||||
Base Metals,Tin,Tin Conc. Burmese 30%min In warehouse MengA RMB/mtm
|
||||
Base Metals,Tin,Tin Ingot 99.9%min EXW China RMB/mt
|
||||
Base Metals,Tin,"Tin Solder Wire Sn 99%min, Ag 0.3%min, Cu 0.7%max EXW China RMB/kg"
|
||||
Base Metals,Tin,"Tin Solder Wire Sn 99%min, Cu 0.7%max EXW China RMB/kg"
|
||||
Base Metals,Zinc,Zinc Conc. 50%min EXW North China RMB/mt Zn
|
||||
Base Metals,Zinc,Zinc Conc. TC 50% CIF China USD/mt
|
||||
Carbon Steel,Coated,HGI Coil DX51D 0.5mm FOB China USD/mt
|
||||
Carbon Steel,Coated,HGI Coil DX51D 0.5mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Coated,HGI Coil DX51D 0.5mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Coated,HGI Coil DX51D 0.5mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Coated,HGI Coil DX51D 1.0mm FOB China USD/mt
|
||||
Carbon Steel,Coated,HGI Coil DX51D 1.0mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Coated,HGI Coil DX51D 1.0mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Coated,HGI Coil DX51D 1.0mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Coated,HGI Coil IS513 0.5mm In warehouse Mumbai INR/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil DC01 1.0mm In warehouse Istanbul USD/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil IS513 1.0mm In warehouse Mumbai INR/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil SPCC 0.5mm FOB China USD/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil SPCC 0.5mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil SPCC 0.5mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil SPCC 0.5mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil SPCC 1.0mm FOB China USD/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil SPCC 1.0mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil SPCC 1.0mm In warehouse Hanoi VND/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil SPCC 1.0mm In warehouse Kuala Lumpur MYR/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil SPCC 1.0mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Cold Rolled Coil,CR Coil SPCC 1.0mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Cold Rolled Coil,"LaCe Chloride TREO 45%min, CeO2/REO 65±2% EXW China RMB/mt"
|
||||
Carbon Steel,Cold Rolled Coil,"LaCe Mischmetal Ce/RE 65%min, Fe 0.3%max EXW China RMB/mt"
|
||||
Carbon Steel,Cold Rolled Coil,Lanthanum Oxide 99.9%min FOB China USD/mt
|
||||
Carbon Steel,Cold Rolled Coil,Lanthanum Oxide 99.999%min EXW China RMB/mt
|
||||
Carbon Steel,Cold Rolled Coil,Lanthanum Oxide 99.999%min FOB China USD/kg
|
||||
Carbon Steel,Hot Rolled Coil,Ferro-gadolinium Gd 73% EXW China RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,Ferro-holmium 80% EXW China RMB/kg
|
||||
Carbon Steel,Hot Rolled Coil,Gadolinium Oxide 99.5%min EXW China RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,Gadolinium Oxide 99.99%min EXW China RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil S275 JR 3.0mm In warehouse Istanbul USD/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil IS2062 3.0mm In warehouse Mumbai INR/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil Q235B 2.75mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil Q235B 2.75mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil Q235B 2.75mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil Q235B 5.5mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil Q235B 5.5mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil Q235B 5.5mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil SS400 2.75mm FOB China USD/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil SS400 3.0mm In warehouse Bangkok THB/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil SS400 3.0mm In warehouse Hanoi VND/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil SS400 3.0mm In warehouse Kuala Lumpur MYR/mt
|
||||
Carbon Steel,Hot Rolled Coil,HR Coil SS400 5.5mm FOB China USD/mt
|
||||
Carbon Steel,Hot Rolled Coil,Holmium Oxide 99.5%min EXW China RMB/kg
|
||||
Carbon Steel,Hot Rolled Coil,Lanthanum Chloride 99.9%min EXW China RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,Lanthanum Metal 99%min EXW China RMB/mt
|
||||
Carbon Steel,Hot Rolled Coil,Lanthanum Metal 99%min FOB China USD/kg
|
||||
Carbon Steel,Hot Rolled Coil,Lanthanum Oxide 99.9%min EXW China RMB/mt
|
||||
Carbon Steel,Pipe,Seamless Pipe 20# ?219*6mm GB8163 In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Pipe,Seamless Pipe 20# ?219*6mm GB8163 In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Pipe,Seamless Pipe 20# ?219*6mm GB8163 In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Pipe,Welded Pipe Q235 ?114*3.75mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Pipe,Welded Pipe Q235 ?114*3.75mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Pipe,Welded Pipe Q235 ?114*3.75mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Plate,Medium Plate IS2062 16mm In warehouse Mumbai INR/mt
|
||||
Carbon Steel,Plate,Medium Plate Q235B 10mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Plate,Medium Plate Q235B 10mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Plate,Medium Plate Q235B 10mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Plate,Medium Plate Q235B 16mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Plate,Medium Plate Q235B 16mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Plate,Medium Plate Q235B 16mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Plate,Medium Plate S275 JR 16mm In warehouse Istanbul USD/mt
|
||||
Carbon Steel,Plate,Medium Plate SS400 10mm FOB China USD/mt
|
||||
Carbon Steel,Plate,Medium Plate SS400 16mm FOB China USD/mt
|
||||
Carbon Steel,Plate,Medium Plate SS400 16mm In warehouse Bangkok THB/mt
|
||||
Carbon Steel,Plate,Medium Plate SS400 16mm In warehouse Hanoi VND/mt
|
||||
Carbon Steel,Plate,Medium Plate SS400 16mm In warehouse Kuala Lumpur MYR/mt
|
||||
Carbon Steel,Rebar,Rebar BS4449 GR500 12mm In warehouse Kuala Lumpur MYR/mt
|
||||
Carbon Steel,Rebar,Rebar BS4449 GR500 16mm In warehouse Kuala Lumpur MYR/mt
|
||||
Carbon Steel,Rebar,Rebar CB400V/SD390 ?14-32mm In warehouse Hanoi VND/mt
|
||||
Carbon Steel,Rebar,Rebar HRB400 12mm FOB China USD/mt
|
||||
Carbon Steel,Rebar,Rebar HRB400 20mm FOB China USD/mt
|
||||
Carbon Steel,Rebar,Rebar HRB400 20mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Rebar,Rebar HRB400 20mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Rebar,Rebar HRB400 20mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Rebar,TMT Bar Fe500 12mm In warehouse Mumbai INR/mt
|
||||
Carbon Steel,Rebar,TMT Bar Fe500 16mm In warehouse Mumbai INR/mt
|
||||
Carbon Steel,Sections,Angle Steel Q235 50*50*5mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Sections,Angle Steel Q235 50*50*5mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Sections,Angle Steel Q235 50*50*5mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Sections,Channel Steel Q235 160*65*8.5mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Sections,Channel Steel Q235 160*65*8.5mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Sections,Channel Steel Q235 160*65*8.5mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Sections,Dysprosium Metal 99.5%min EXW China RMB/kg
|
||||
Carbon Steel,Sections,Dysprosium Metal 99.5%min FOB China USD/kg
|
||||
Carbon Steel,Sections,Dysprosium Oxide 99.5%min EXW China RMB/kg
|
||||
Carbon Steel,Sections,Dysprosium Oxide 99.5%min FOB China USD/kg
|
||||
Carbon Steel,Sections,Erbium Oxide 99.5%min EXW China RMB/kg
|
||||
Carbon Steel,Sections,Erbium Oxide 99.5%min FOB China USD/kg
|
||||
Carbon Steel,Sections,Europium Metal 99.5%min FOB China USD/kg
|
||||
Carbon Steel,Sections,Europium Oxide 99.999%min EXW China RMB/kg
|
||||
Carbon Steel,Sections,Europium Oxide 99.999%min FOB China USD/kg
|
||||
Carbon Steel,Sections,Ferro-dysprosium 80% EXW China RMB/kg
|
||||
Carbon Steel,Sections,Ferro-dysprosium 80% FOB China USD/kg
|
||||
Carbon Steel,Sections,H-beam Steel Q235 200*200mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Sections,H-beam Steel Q235 200*200mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Sections,H-beam Steel Q235 200*200mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Sections,H-beam Steel Q235 300*300mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Sections,H-beam Steel Q235 300*300mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Sections,H-beam Steel Q235 300*300mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Sections,I-beam Steel Q235 250*118*10mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Sections,I-beam Steel Q235 250*118*10mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Sections,I-beam Steel Q235 250*118*10mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Strip,HR Strip Q195 295*2.5mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Strip,HR Strip Q195 295*2.5mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Strip,HR Strip Q195 295*2.5mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Strip,HR Strip Q195 685*2.75mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Strip,HR Strip Q235B 685*5.5mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Strip,HR Strip Q235B 685*5.5mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Wire Rod,"LaCe Mischmetal Ce/RE 65%min, Fe 0.3%max EXW China RMB/mt"
|
||||
Carbon Steel,Wire Rod,"LaCe Mischmetal Ce/RE 65%min, Fe 1%max EXW China RMB/mt"
|
||||
Carbon Steel,Wire Rod,"LaCe Mischmetal Ce/RE 65%min, Fe 1%max FOB China USD/kg"
|
||||
Carbon Steel,Wire Rod,Wire Rod JIS SWRM15 ?6.5mm In warehouse Bangkok THB/mt
|
||||
Carbon Steel,Wire Rod,Wire Rod Q195 6.5mm FOB China USD/mt
|
||||
Carbon Steel,Wire Rod,Wire Rod Q235 8mm In warehouse Guangzhou RMB/mt
|
||||
Carbon Steel,Wire Rod,Wire Rod Q235 8mm In warehouse Shanghai RMB/mt
|
||||
Carbon Steel,Wire Rod,Wire Rod Q235 8mm In warehouse Tianjin RMB/mt
|
||||
Carbon Steel,Wire Rod,Wire Rod SAE1008 5.5mm In warehouse Kuala Lumpur MYR/mt
|
||||
Carbon Steel,Wire Rod,Wire Rod SAE1008 5.5mm In warehouse Mumbai INR/mt
|
||||
Carbon Steel,Wire Rod,Wire Rod SAE1008 6.5mm FOB China USD/mt
|
||||
Ferroalloys,Calcium Silicon,Calcium-silicon 28-55 EXW China RMB/mt
|
||||
Ferroalloys,Calcium Silicon,Calcium-silicon 28-55 FOB China USD/mt
|
||||
Ferroalloys,Calcium Silicon,Calcium-silicon 30-60 EXW China RMB/mt
|
||||
Ferroalloys,Calcium Silicon,Calcium-silicon 30-60 FOB China USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. 40%min FOB S.A. USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. 42%min FOB S.A. USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. 46%min FOB Turkey USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. S.A. 40%min CIF China USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. S.A. 40%min In port China RMB/dmtu
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. S.A. 42%min CIF China USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. S.A. 42%min In port China RMB/dmtu
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. Turkish 46%min CIF China USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. Turkish 46%min In port China RMB/dmtu
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. Zimbabwean 48%min CIF China USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Conc. Zimbabwean 48%min In port China RMB/dmtu
|
||||
Ferroalloys,Chrome Ore,Chrome Lump 40%min FOB Turkey USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Lump S.A. 38%min CIF China USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Lump S.A. 38%min In port China RMB/dmtu
|
||||
Ferroalloys,Chrome Ore,Chrome Lump Turkish 40%min CIF China USD/mt
|
||||
Ferroalloys,Chrome Ore,Chrome Lump Turkish 40%min In port China RMB/dmtu
|
||||
Ferroalloys,Chromium Silicon,"Silico-chrome Si 42%min, Cr 30%min EXW China RMB/mt"
|
||||
Ferroalloys,Ferroboron,Ferro-boron B 18%min EXW China RMB/mt
|
||||
Ferroalloys,Ferroboron,Ferro-boron B 18%min FOB China USD/mt
|
||||
Ferroalloys,Ferroboron,Ferro-boron B 19%min EXW China RMB/mt
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Cr 52%min, C 8%max EXW China RMB/mt 50-basis"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Cr 55%min, C 0.1%max EXW China RMB/mt 60-basis"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Cr 55%min, C 0.25%max EXW China RMB/mt 60-basis"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Cr 60%min, C 0.1%max FOB China USD/lb Cr"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Cr 60%min, C 0.1%max In warehouse Pittsburgh USD/lb Cr"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Cr 60%min, C 0.1%max In warehouse Rotterdam USD/lb Cr"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Cr 60%min, C 8%max In warehouse Pittsburg USD/lb Cr"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Cr 60%min, C 8%max In warehouse Rotterdam USD/lb Cr"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Cr 60%min, C 8.5%max In warehouse Russia RUB/kg"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Cr 65%min, C 0.1%max In warehouse Russia RUB/kg"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Indian Cr 58%min, C 8%max CIF China USD/lb Cr"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Indian Cr 58%min, C 8%max In port China RMB/mt 50-basis"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Kazakhstan Cr 68%min, C 8.5%max CIF China USD/lb Cr"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome Kazakhstan Cr 68%min, C 8.5%max In port China RMB/mt 50-basis"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome S.A. Cr 50%min, C 8%max CIF China USD/lb Cr"
|
||||
Ferroalloys,Ferrochrome,"Ferro-chrome S.A. Cr 50%min, C 8%max In port China RMB/mt 50-basis"
|
||||
Ferroalloys,Ferromanganese,"Ferro-manganese Mn 65%min, C 7%max EXW China RMB/mt"
|
||||
Ferroalloys,Ferromanganese,"Ferro-manganese Mn 75%min, C 2%max EXW China RMB/mt"
|
||||
Ferroalloys,Ferromanganese,"Ferro-manganese Mn 75%min, C 8%max EXW China RMB/mt"
|
||||
Ferroalloys,Ferromanganese,"Ferro-manganese Mn 78%min, C 1.5%max EXW China RMB/mt"
|
||||
Ferroalloys,Ferromanganese,"Ferro-manganese Mn 78%min, C 7%max In warehouse Russia RUB/kg"
|
||||
Ferroalloys,Ferromanganese,"Ferro-manganese Mn 78%min, C 8%max Delivered Europe EUR/mt"
|
||||
Ferroalloys,Ferromanganese,"Ferro-manganese Mn 78%min, C 8%max In warehouse Pittsburgh USD/gt"
|
||||
Ferroalloys,Ferromanganese,"Ferro-manganese Mn 80%min, C 0.7%max EXW China RMB/mt"
|
||||
Ferroalloys,Ferromolybdenum,Ferro-molybdenum 60%min Delivered India INR/kg
|
||||
Ferroalloys,Ferromolybdenum,Ferro-molybdenum 60%min EXW China RMB/mt
|
||||
Ferroalloys,Ferromolybdenum,Ferro-molybdenum 60%min In port South Korea USD/kg Mo
|
||||
Ferroalloys,Ferromolybdenum,Ferro-molybdenum 60%min In warehouse Russia RUB/kg
|
||||
Ferroalloys,Ferromolybdenum,Ferro-molybdenum 65%min In warehouse Rotterdam USD/kg Mo
|
||||
Ferroalloys,Ferromolybdenum,Ferro-molybdenum 65%min In warehouse US USD/kg Mo
|
||||
Ferroalloys,Ferromolybdenum,Molybdenum Oxide 57%min In port South Korea USD/lb
|
||||
Ferroalloys,Ferromolybdenum,Molybdenum Oxide 57%min In warehouse Rotterdam USD/lb
|
||||
Ferroalloys,Ferronickel,Ferro-nickel 1.5%min EXW China RMB/mt
|
||||
Ferroalloys,Ferronickel,Ferro-nickel 10%min EXW China RMB/mtu
|
||||
Ferroalloys,Ferronickel,Nickel Ore Philippine 1.5%min CIF China USD/mt
|
||||
Ferroalloys,Ferronickel,Nickel Ore Philippine 1.8%min In port China RMB/mt
|
||||
Ferroalloys,Ferroniobium,Ferro-niobium 50-A EXW China RMB/mt
|
||||
Ferroalloys,Ferroniobium,Ferro-niobium 60-A EXW China RMB/mt
|
||||
Ferroalloys,Ferroniobium,Ferro-niobium Brazilian 66% Delivered China RMB/mt
|
||||
Ferroalloys,Ferroniobium,Ferro-niobium Brazilian 66% In warehouse Rotterdam USD/kg Nb
|
||||
Ferroalloys,Ferroniobium,"Niobium Conc. Nb2O5 50%min, Ta2O5 5%min, CIF China USD/lb Oxides"
|
||||
Ferroalloys,Ferroniobium,Niobium Pentoxide 99.5%min EXW China RMB/kg
|
||||
Ferroalloys,Ferrophosphorus,Ferro-phosphorus 23%min FOB China USD/mt
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 65%min FOB Russia USD/mt
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 65%min In warehouse Russia RUB/kg
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 70%min EXW India INR/mt
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 72%min EXW China RMB/mt
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 72%min Ex-VAT FOB China USD/mt
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 72%min FOB China USD/mt
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 75%min EXW China RMB/mt
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 75%min Ex-VAT FOB China USD/mt
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 75%min FOB China USD/mt
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 75%min In warehouse Pittsburgh USD/lb Si
|
||||
Ferroalloys,Ferrosilicon,Ferro-silicon 75%min In warehouse Rotterdam EUR/mt
|
||||
Ferroalloys,Ferrotitanium,Ferro-titanium 30% EXW China RMB/mt 25-basis
|
||||
Ferroalloys,Ferrotitanium,Ferro-titanium 30% EXW India INR/kg
|
||||
Ferroalloys,Ferrotitanium,Ferro-titanium 70% (scrap) EXW China RMB/mt
|
||||
Ferroalloys,Ferrotitanium,Ferro-titanium 70% In port India INR/kg
|
||||
Ferroalloys,Ferrotitanium,Ferro-titanium 70%min Delivered US USD/kg Ti
|
||||
Ferroalloys,Ferrotitanium,Ferro-titanium 70%min EXW Russia RUB/kg
|
||||
Ferroalloys,Ferrotitanium,Ferro-titanium 70%min FOB Russia USD/kg Ti
|
||||
Ferroalloys,Ferrotitanium,Ferro-titanium 70%min In warehouse Rotterdam USD/kg Ti
|
||||
Ferroalloys,Ferrotungsten,Ferro-tungsten 70%min EXW China RMB/mt
|
||||
Ferroalloys,Ferrotungsten,Ferro-tungsten 75%min EXW China RMB/mt
|
||||
Ferroalloys,Ferrotungsten,Ferro-tungsten 75%min FOB China USD/kg W
|
||||
Ferroalloys,Ferrotungsten,Ferro-tungsten 75%min FOB Russia USD/kg W
|
||||
Ferroalloys,Ferrotungsten,Ferro-tungsten 75%min In warehouse Rotterdam USD/kg W
|
||||
Ferroalloys,Ferrovanadium,Ferro-vanadium 50%min CIF China USD/kg V
|
||||
Ferroalloys,Ferrovanadium,Ferro-vanadium 50%min Delivered China RMB/mt
|
||||
Ferroalloys,Ferrovanadium,Ferro-vanadium 50%min In port China RMB/mt
|
||||
Ferroalloys,Ferrovanadium,Ferro-vanadium 50%min In warehouse Russia RUB/kg
|
||||
Ferroalloys,Ferrovanadium,Ferro-vanadium 80%min FOB China USD/kg V
|
||||
Ferroalloys,Ferrovanadium,Ferro-vanadium 80%min In warehouse Pittsburgh USD/kg V
|
||||
Ferroalloys,Ferrovanadium,Ferro-vanadium 80%min In warehouse Rotterdam USD/kg V
|
||||
Ferroalloys,Ferrovanadium,Vanadium Nitride N 14%min EXW China RMB/mt
|
||||
Ferroalloys,Ferrovanadium,Vanadium Pentoxide Flake 98%min In warehouse Rotterdam USD/lb V2O5
|
||||
Ferroalloys,Manganese Ore,Manganese Ore 38%min FOB S.A. USD/dmtu
|
||||
Ferroalloys,Manganese Ore,Manganese Ore Australian 45%min In Qinzhou Port RMB/mtu
|
||||
Ferroalloys,Manganese Ore,Manganese Ore Australian 45%min In Tianjin Port RMB/mtu
|
||||
Ferroalloys,Manganese Ore,Manganese Ore Brazilian 44%min In Qinzhou Port RMB/mtu
|
||||
Ferroalloys,Manganese Ore,Manganese Ore Brazilian 44%min In Tianjin Port RMB/mtu
|
||||
Ferroalloys,Manganese Ore,"Manganese Ore Carbonate, Mn 13%min EXW China RMB/mtu"
|
||||
Ferroalloys,Manganese Ore,Manganese Ore Gabonese 44%min In Qinzhou Port RMB/mtu
|
||||
Ferroalloys,Manganese Ore,Manganese Ore Gabonese 44%min In Tianjin Port RMB/mtu
|
||||
Ferroalloys,Manganese Ore,Manganese Ore S.A. 36%min In Qinzhou Port RMB/mtu
|
||||
Ferroalloys,Manganese Ore,Manganese Ore S.A. 36%min In Tianjin Port RMB/mtu
|
||||
Ferroalloys,Manganese Ore,Manganese Ore S.A. 37%min In Qinzhou Port RMB/mtu
|
||||
Ferroalloys,Manganese Ore,Manganese Ore S.A. 37%min In Tianjin Port RMB/mtu
|
||||
Ferroalloys,Silicomanganese,Silico-manganese 60/14 EXW China RMB/mt
|
||||
Ferroalloys,Silicomanganese,Silico-manganese 60/14 FOB India USD/mt
|
||||
Ferroalloys,Silicomanganese,Silico-manganese 65/17 Delivered Europe EUR/mt
|
||||
Ferroalloys,Silicomanganese,Silico-manganese 65/17 EXW China RMB/mt
|
||||
Ferroalloys,Silicomanganese,Silico-manganese 65/17 FOB India USD/mt
|
||||
Ferroalloys,Silicomanganese,Silico-manganese 65/17 In warehouse Pittsburgh USD/lb
|
||||
Ferroalloys,Silicomanganese,Silico-manganese 65/17 In warehouse Russia RUB/kg
|
||||
Minor Metals,Antimony,Antimony Conc. 50%min Delivered China RMB/mt Sb
|
||||
Minor Metals,Antimony,Antimony Ingot 99.65%min EXW China RMB/mt
|
||||
Minor Metals,Antimony,Antimony Ingot 99.65%min In port India INR/kg
|
||||
Minor Metals,Antimony,Antimony Ingot 99.65%min In warehouse Baltimore USD/lb
|
||||
Minor Metals,Antimony,Antimony Ingot 99.65%min In warehouse Rotterdam USD/mt
|
||||
Minor Metals,Antimony,Antimony Ingot 99.85%min EXW China RMB/mt
|
||||
Minor Metals,Antimony,Antimony Trioxide 99.5%min Delivered China RMB/mt
|
||||
Minor Metals,Antimony,Antimony Trioxide 99.5%min FOB China USD/mt
|
||||
Minor Metals,Antimony,Antimony Trioxide 99.5%min In warehouse Baltimore USD/lb
|
||||
Minor Metals,Antimony,Antimony Trioxide 99.5%min In warehouse Rotterdam EUR/kg
|
||||
Minor Metals,Antimony,Antimony Trioxide 99.8%min Delivered China RMB/mt
|
||||
Minor Metals,Antimony,Ethylene Glycol Antimony 57%min Delivered China RMB/mt
|
||||
Minor Metals,Arsenic,Arsenic Metal 99%min EXW China RMB/mt
|
||||
Minor Metals,Arsenic,Arsenic Metal 99%min FOB China USD/mt
|
||||
Minor Metals,Arsenic,Arsenic Trioxide 99%min EXW China RMB/mt
|
||||
Minor Metals,Arsenic,Arsenic Trioxide 99%min FOB China USD/mt
|
||||
Minor Metals,Bismuth,Bismuth Ingot 99.99%min Delivered Europe USD/lb
|
||||
Minor Metals,Bismuth,Bismuth Ingot 99.99%min Delivered India INR/kg
|
||||
Minor Metals,Bismuth,Bismuth Ingot 99.99%min Delivered US USD/lb
|
||||
Minor Metals,Bismuth,Bismuth Ingot 99.99%min EXW China RMB/mt
|
||||
Minor Metals,Bismuth,Bismuth Ingot 99.99%min FOB China USD/lb
|
||||
Minor Metals,Bismuth,Bismuth Oxide 99.9%min EXW China RMB/mt
|
||||
Minor Metals,Cadmium,Cadmium Ingot 99.99%min Delivered India INR/kg
|
||||
Minor Metals,Cadmium,Cadmium Ingot 99.99%min EXW China RMB/mt
|
||||
Minor Metals,Cadmium,Cadmium Ingot 99.99%min In warehouse Baltimore USD/lb
|
||||
Minor Metals,Cadmium,Cadmium Ingot 99.99%min In warehouse Rotterdam USD/lb
|
||||
Minor Metals,Calcium,Calcium Granule Electrolytic 98.5%min EXW China RMB/mt
|
||||
Minor Metals,Calcium,Calcium Granule Electrolytic 98.5%min FOB China USD/mt
|
||||
Minor Metals,Calcium,Calcium Granule Reductive 98%min EXW China RMB/mt
|
||||
Minor Metals,Calcium,Calcium Granule Reductive 98%min FOB China USD/mt
|
||||
Minor Metals,Calcium,Calcium Ingot Reductive 98%min EXW China RMB/mt
|
||||
Minor Metals,Calcium,Fluorspar 97%min Delivered China RMB/mt
|
||||
Minor Metals,Chromium,Chromic Anhydride 99.8%min EXW China RMB/mt
|
||||
Minor Metals,Chromium,Chromium Metal 99%min EXW China RMB/mt
|
||||
Minor Metals,Chromium,Chromium Metal 99%min FOB China USD/mt
|
||||
Minor Metals,Chromium,Chromium Oxide Green 99%min EXW China RMB/mt
|
||||
Minor Metals,Cobalt,Cobalt Carbonate 46%min Delivered China RMB/mt
|
||||
Minor Metals,Cobalt,Cobalt Chloride 24%min Delivered China RMB/mt
|
||||
Minor Metals,Cobalt,Cobalt Conc. 6-8% CIF China USD/lb Co
|
||||
Minor Metals,Cobalt,Cobalt Intermediate 20-30% CIF China USD/lb Co
|
||||
Minor Metals,Cobalt,Cobalt Metal 99.8%min Delivered China RMB/kg
|
||||
Minor Metals,Cobalt,Cobalt Metal 99.8%min In warehouse Baltimore USD/lb
|
||||
Minor Metals,Cobalt,Cobalt Metal 99.8%min In warehouse Rotterdam USD/lb
|
||||
Minor Metals,Cobalt,Cobalt Monoxide 78%min Delivered China RMB/kg
|
||||
Minor Metals,Cobalt,Cobalt Oxide 72%min Delivered China RMB/kg
|
||||
Minor Metals,Cobalt,Cobalt Oxide 72%min In warehouse Rotterdam EUR/kg
|
||||
Minor Metals,Cobalt,Cobalt Powder 99.5%min Delivered China RMB/kg
|
||||
Minor Metals,Cobalt,Cobalt Sulfate 20.5%min Delivered China RMB/mt
|
||||
Minor Metals,Cobalt,Cobalt Tetroxide 73.5%min Delivered China RMB/kg
|
||||
Minor Metals,Cobalt,Lithium Cobaltate Co 60%min Delivered China RMB/kg
|
||||
Minor Metals,Cobalt,NCM 523 Delivered China RMB/mt
|
||||
Minor Metals,Cobalt,NCM 622 Delivered China RMB/mt
|
||||
Minor Metals,Cobalt,Samarium Cobalt 30H EXW China RMB/kg
|
||||
Minor Metals,Gallium,Gallium Metal 99.99%min EXW China RMB/kg
|
||||
Minor Metals,Gallium,Gallium Metal 99.99%min FOB China USD/kg
|
||||
Minor Metals,Gallium,Gallium Metal 99.99999%min EXW China RMB/kg
|
||||
Minor Metals,Germanium,Germanium Dioxide 99.99%min Delivered US USD/kg
|
||||
Minor Metals,Germanium,Germanium Dioxide 99.99%min EXW China RMB/kg
|
||||
Minor Metals,Germanium,Germanium Dioxide 99.99%min FOB China USD/kg
|
||||
Minor Metals,Germanium,Germanium Metal 99.99%min Delivered Europe USD/kg
|
||||
Minor Metals,Germanium,Germanium Metal 99.99%min Delivered US USD/kg
|
||||
Minor Metals,Germanium,Germanium Metal 99.99%min EXW China RMB/kg
|
||||
Minor Metals,Germanium,Germanium Metal 99.99%min FOB China USD/kg
|
||||
Minor Metals,Indium,Indium Ingot 99.995%min Delivered Europe USD/kg
|
||||
Minor Metals,Indium,Indium Ingot 99.995%min Delivered US USD/kg
|
||||
Minor Metals,Indium,Indium Ingot 99.995%min EXW China RMB/kg
|
||||
Minor Metals,Indium,Indium Ingot 99.995%min FOB China USD/kg
|
||||
Minor Metals,Lithium,LNCMO 523 Delivered China RMB/kg
|
||||
Minor Metals,Lithium,LNCMO 622 Delivered China RMB/kg
|
||||
Minor Metals,Lithium,Lithium Carbonate 99%min Delivered China RMB/mt
|
||||
Minor Metals,Lithium,Lithium Carbonate 99.5%min CIF China USD/kg
|
||||
Minor Metals,Lithium,Lithium Carbonate 99.5%min Delivered China RMB/mt
|
||||
Minor Metals,Lithium,Lithium Carbonate 99.5%min Delivered EU USD/kg
|
||||
Minor Metals,Lithium,Lithium Carbonate 99.5%min Delivered US USD/kg
|
||||
Minor Metals,Lithium,Lithium Carbonate 99.5%min FOB South America USD/kg
|
||||
Minor Metals,Lithium,Lithium Chloride 99.3%min Delivered China RMB/mt
|
||||
Minor Metals,Lithium,Lithium Hydroxide Monohydrate LiOH 56.5%min Delivered China RMB/mt
|
||||
Minor Metals,Lithium,"Lithium Hydroxide Monohydrate LiOH 56.5%min, Magnets 0.0001%max Delivered China RMB/mt"
|
||||
Minor Metals,Lithium,"Lithium Hydroxide Monohydrate LiOH 56.5%min, Magnets 0.0001%max Delivered South Korea USD/kg"
|
||||
Minor Metals,Lithium,"Lithium Hydroxide Monohydrate LiOH 56.5%min, Magnets 0.0001%max FOB China USD/kg"
|
||||
Minor Metals,Lithium,Lithium Iron Phosphate Li 3.9%min Delivered China RMB/mt
|
||||
Minor Metals,Lithium,Lithium Manganate Mn 58% Delivered China RMB/mt
|
||||
Minor Metals,Lithium,Lithium Metal 99%min Delivered China RMB/mt
|
||||
Minor Metals,Lithium,Lithium Metal 99.9%min Delivered China RMB/mt
|
||||
Minor Metals,Lithium,Spodumene Li2O 6%min CIF China USD/mt
|
||||
Minor Metals,Magnesium,Magnesium Alloy AM50A EXW China RMB/mt
|
||||
Minor Metals,Magnesium,Magnesium Alloy AM50A FOB China USD/mt
|
||||
Minor Metals,Magnesium,Magnesium Alloy AM60B EXW China RMB/mt
|
||||
Minor Metals,Magnesium,Magnesium Alloy AM60B FOB China USD/mt
|
||||
Minor Metals,Magnesium,Magnesium Alloy AZ91D EXW China RMB/mt
|
||||
Minor Metals,Magnesium,Magnesium Alloy AZ91D FOB China USD/mt
|
||||
Minor Metals,Magnesium,Magnesium Ingot 99.9%min EXW China RMB/mt
|
||||
Minor Metals,Magnesium,Magnesium Ingot 99.9%min Ex-VAT FOB China USD/mt
|
||||
Minor Metals,Magnesium,Magnesium Ingot 99.9%min FOB China USD/mt
|
||||
Minor Metals,Magnesium,Magnesium Ingot 99.9%min In port India INR/kg
|
||||
Minor Metals,Magnesium,Magnesium Ingot 99.9%min In warehouse Rotterdam USD/mt
|
||||
Minor Metals,Magnesium,Magnesium Ingot 99.9%min in warehouse Russia RUB/kg
|
||||
Minor Metals,Magnesium,Magnesium Ingot 99.95%min EXW China RMB/mt
|
||||
Minor Metals,Magnesium,Magnesium Powder 99.9%min EXW China RMB/mt
|
||||
Minor Metals,Magnesium,Magnesium Powder 99.9%min FOB China USD/mt
|
||||
Minor Metals,Manganese,Manganese Briquette 97%min FOB China USD/mt
|
||||
Minor Metals,Manganese,Manganese Dioxide Alkaline 91%min EXW China RMB/mt
|
||||
Minor Metals,Manganese,Manganese Dioxide Carbon 91%min EXW China RMB/mt
|
||||
Minor Metals,Manganese,Manganese Flake 99.7%min CIF India USD/mt
|
||||
Minor Metals,Manganese,Manganese Flake 99.7%min EXW China RMB/mt
|
||||
Minor Metals,Manganese,Manganese Flake 99.7%min Ex-VAT FOB China USD/mt
|
||||
Minor Metals,Manganese,Manganese Flake 99.7%min FOB China USD/mt
|
||||
Minor Metals,Manganese,Manganese Flake 99.7%min In warehouse Rotterdam USD/mt
|
||||
Minor Metals,Manganese,Manganese Lump 96.5%min FOB China USD/mt
|
||||
Minor Metals,Manganese,Manganese Lump 96.5%min In warehouse Russia RUB/kg
|
||||
Minor Metals,Manganese,Manganese Sulfate Mn 32%min EXW China RMB/t
|
||||
Minor Metals,Mercury,Mercury Metal 99.9%min Ex-VAT EXW China RMB/kg
|
||||
Minor Metals,Mercury,Mercury Metal 99.999%min Ex-VAT EXW China RMB/kg
|
||||
Minor Metals,Molybdenum,Ammonium Heptamolybdate 54%min EXW China RMB/mt
|
||||
Minor Metals,Molybdenum,Ammonium Tetrathiomolybdate 56%min EXW China RMB/mt
|
||||
Minor Metals,Molybdenum,Molybdenum Bar 99.9%min EXW China RMB/kg
|
||||
Minor Metals,Molybdenum,Molybdenum Conc. 45%min EXW China RMB/mtu
|
||||
Minor Metals,Molybdenum,Molybdenum Oxide 57%min In port India USD/lb
|
||||
Minor Metals,Molybdenum,Molybdenum Oxide 50%min EXW China RMB/mtu
|
||||
Minor Metals,Molybdenum,Molybdenum Oxide 57%min CIF China USD/lb Mo
|
||||
Minor Metals,Molybdenum,Molybdenum Oxide 57%min In port South Korea USD/lb
|
||||
Minor Metals,Molybdenum,Molybdenum Oxide 57%min In warehouse Rotterdam USD/lb
|
||||
Minor Metals,Molybdenum,Molybdenum Oxide 99.9%min EXW China RMB/mt
|
||||
Minor Metals,Molybdenum,Molybdenum Powder 99.95%min EXW China RMB/kg
|
||||
Minor Metals,Molybdenum,Molybdenum Scrap Cutting Scrap 99.95%min Ex-VAT EXW China RMB/kg
|
||||
Minor Metals,Molybdenum,Molybdenum Scrap Wire Scrap 99.95%min Ex-VAT EXW China RMB/kg
|
||||
Minor Metals,Molybdenum,Sodium Molybdate 99%min EXW China RMB/mt
|
||||
Minor Metals,Niobium,"Niobium Conc. Nb2O5 50%min, Ta2O5 5%min, CIF China USD/lb Oxides"
|
||||
Minor Metals,Niobium,Niobium Pentoxide 99.5%min EXW China RMB/kg
|
||||
Minor Metals,Niobium,Niobium Pentoxide 99.5%min FOB China USD/kg
|
||||
Minor Metals,Niobium,Niobium Pentoxide 99.99%min EXW China RMB/kg
|
||||
Minor Metals,Niobium,Niobium Pentoxide 99.99%min FOB China USD/kg
|
||||
Minor Metals,Rhenium,Rhenium APR 99.99%min EXW China RMB/kg
|
||||
Minor Metals,Selenium,Selenium Dioxide 98%min Delivered China RMB/kg
|
||||
Minor Metals,Selenium,Selenium Powder 99.5%min In warehouse Rotterdam USD/lb
|
||||
Minor Metals,Selenium,Selenium Powder 99.9%min CIF China USD/lb
|
||||
Minor Metals,Selenium,Selenium Powder 99.9%min Delivered China RMB/kg
|
||||
Minor Metals,Selenium,Selenium Powder 99.9%min Delivered India INR/kg
|
||||
Minor Metals,Selenium,Selenium Powder 99.9%min Delivered US USD/lb
|
||||
Minor Metals,Selenium,Selenium Powder 99.9%min FOB Russia USD/kg
|
||||
Minor Metals,Selenium,Selenium Powder 99.95%min Delivered China RMB/kg
|
||||
Minor Metals,Silicon,Polysilicon 9N Delivered China RMB/kg
|
||||
Minor Metals,Silicon,Silicon Metal 2-2-02 Delivered China RMB/mt
|
||||
Minor Metals,Silicon,Silicon Metal 2-2-02 Ex-VAT FOB China USD/mt
|
||||
Minor Metals,Silicon,Silicon Metal 2-2-02 FOB China USD/mt
|
||||
Minor Metals,Silicon,Silicon Metal 3-3-03 Delivered China RMB/mt
|
||||
Minor Metals,Silicon,Silicon Metal 3-3-03 Ex-VAT FOB China USD/mt
|
||||
Minor Metals,Silicon,Silicon Metal 3-3-03 FOB China USD/mt
|
||||
Minor Metals,Silicon,Silicon Metal 3-3-03 P 50ppm max Delivered China RMB/mt
|
||||
Minor Metals,Silicon,Silicon Metal 3-3-03 P 50ppm max Ex-VAT FOB China USD/mt
|
||||
Minor Metals,Silicon,Silicon Metal 3-3-03 P 50ppm max FOB China USD/mt
|
||||
Minor Metals,Silicon,Silicon Metal 4-2-1 Delivered China RMB/mt
|
||||
Minor Metals,Silicon,Silicon Metal 4-4-1 Delivered China RMB/mt
|
||||
Minor Metals,Silicon,Silicon Metal 4-4-1 Delivered Europe EUR/mt
|
||||
Minor Metals,Silicon,Silicon Metal 4-4-1 Ex-VAT FOB China USD/mt
|
||||
Minor Metals,Silicon,Silicon Metal 4-4-1 FOB China USD/mt
|
||||
Minor Metals,Silicon,Silicon Metal 4-4-1 In port India INR/kg
|
||||
Minor Metals,Silicon,Silicon Metal 5-5-3 Delivered China RMB/mt
|
||||
Minor Metals,Silicon,Silicon Metal 5-5-3 Delivered Europe EUR/mt
|
||||
Minor Metals,Silicon,Silicon Metal 5-5-3 Ex-VAT FOB China USD/mt
|
||||
Minor Metals,Silicon,Silicon Metal 5-5-3 FOB China USD/mt
|
||||
Minor Metals,Silicon,Silicon Metal 5-5-3 In port India INR/kg
|
||||
Minor Metals,Silicon,Silicon Metal 98.5%min Delivered US USD/lb
|
||||
Minor Metals,Silicon,Silicon Powder 99%min Delivered China RMB/mt
|
||||
Minor Metals,Strontium,Strontium Metal 99%min EXW China RMB/mt
|
||||
Minor Metals,Strontium,Strontium Metal 99%min FOB China USD/mt
|
||||
Minor Metals,Tantalum,Tantalum Conc. Ta2O5 30%min CIF China USD/lb Ta2O5
|
||||
Minor Metals,Tantalum,Tantalum Conc. Ta2O5 30%min in warehouse Rotterdam USD/lb Ta2O5
|
||||
Minor Metals,Tantalum,Tantalum Metal 99.95%min Delivered US USD/kg
|
||||
Minor Metals,Tantalum,Tantalum Metal 99.95%min EXW China RMB/kg
|
||||
Minor Metals,Tantalum,Tantalum Metal 99.95%min FOB China USD/kg
|
||||
Minor Metals,Tantalum,Tantalum Pentoxide 99.5%min EXW China RMB/kg
|
||||
Minor Metals,Tantalum,Tantalum Pentoxide 99.5%min FOB China USD/kg
|
||||
Minor Metals,Tantalum,Tantalum Pentoxide 99.99%min EXW China RMB/kg
|
||||
Minor Metals,Tantalum,Tantalum Pentoxide 99.99%min FOB China USD/kg
|
||||
Minor Metals,Tellurium,Tellurium Metal 99.9%min In warehouse Rotterdam USD/kg
|
||||
Minor Metals,Tellurium,Tellurium Metal 99.99%min EXW China RMB/kg
|
||||
Minor Metals,Tellurium,Tellurium Metal 99.99%min In warehouse Rotterdam USD/kg
|
||||
Minor Metals,Titanium,"Ilmenite Conc Australian TiO2 55-58%min, Fe 27%min CIF China USD/mt"
|
||||
Minor Metals,Titanium,"Ilmenite Conc Kenyan TiO2 48%min, Fe2O3 20%max CIF China USD/mt"
|
||||
Minor Metals,Titanium,"Ilmenite Conc Mozambican TiO2 52%min, Fe2O3 25%max CIF China USD/mt"
|
||||
Minor Metals,Titanium,Ilmenite Conc TiO2 46%min; Fe2O3 8%max; Ex-VAT EXW China RMB/mt
|
||||
Minor Metals,Titanium,Ilmenite Conc TiO2 50%min; Fe 30%min; In port China RMB/mt
|
||||
Minor Metals,Titanium,Ilmenite Conc TiO2 50%min; Fe2O3 14%max; In port China RMB/mt
|
||||
Minor Metals,Titanium,"Ilmenite Conc TiO2 52%min, Fe2O3 25%max FOB Vietnam USD/mt"
|
||||
Minor Metals,Titanium,Reduced Ilmenite TiO2 56%min EXW China RMB/mt
|
||||
Minor Metals,Titanium,Rutile Conc TiO2 95%min In port China RMB/mt
|
||||
Minor Metals,Titanium,Titanium Dioxide A TiO2 98%min Delivered China RMB/mt
|
||||
Minor Metals,Titanium,Titanium Dioxide R TiO2 93%min Delivered China RMB/mt
|
||||
Minor Metals,Titanium,Titanium Dioxide R TiO2 93%min FOB China USD/mt
|
||||
Minor Metals,Titanium,Titanium Pipe TA2 25.4x1mm EXW China RMB/kg
|
||||
Minor Metals,Titanium,Titanium Plate TA2 2mm EXW China RMB/kg
|
||||
Minor Metals,Titanium,Titanium Slag TiO2 74%min EXW China RMB/mt
|
||||
Minor Metals,Titanium,Titanium Slag TiO2 90%min EXW China RMB/mt
|
||||
Minor Metals,Titanium,Titanium Sponge 99.7%min EXW China RMB/mt
|
||||
Minor Metals,Titanium,Titanium Tetrachloride 99.99%min EXW China RMB/mt
|
||||
Minor Metals,Tungsten,Sodium Tungstate WO3 68%min EXW China RMB/mt
|
||||
Minor Metals,Tungsten,Thorium Tungsten Electrode ThO2 1.7-2.2% EXW China RMB/kg
|
||||
Minor Metals,Tungsten,Tungsten APT 88.5%min EXW China RMB/mt
|
||||
Minor Metals,Tungsten,Tungsten APT 88.5%min FOB China USD/mtu
|
||||
Minor Metals,Tungsten,Tungsten APT 88.5%min In warehouse Rotterdam USD/mtu
|
||||
Minor Metals,Tungsten,Tungsten Bar W-4 99.9%min EXW China RMB/kg
|
||||
Minor Metals,Tungsten,Tungsten Bar W-4 99.9%min FOB China USD/kg
|
||||
Minor Metals,Tungsten,Tungsten Carbide 99.8%min 2.5-7.0?m EXW China RMB/kg
|
||||
Minor Metals,Tungsten,Tungsten Carbide 99.8%min 2.5-7.0?m FOB China USD/kg
|
||||
Minor Metals,Tungsten,Tungsten Ore WO3 50%min FOB Africa USD/mtu
|
||||
Minor Metals,Tungsten,Tungsten Ore WO3 65%min EXW China RMB/mt
|
||||
Minor Metals,Tungsten,Tungsten Oxide WO3 99.95%min EXW China RMB/mt
|
||||
Minor Metals,Tungsten,Tungsten Oxide WO3 99.95%min FOB China USD/mtu
|
||||
Minor Metals,Tungsten,Tungsten Powder 99.95%min 2.5-7.0?m EXW China RMB/kg
|
||||
Minor Metals,Tungsten,Tungsten Scrap Anvil 90%min Ex-VAT Delivered China RMB/kg
|
||||
Minor Metals,Tungsten,Tungsten Scrap CNC Cutting Blades 70%min Ex-VAT Delivered China RMB/kg
|
||||
Minor Metals,Tungsten,Tungsten Scrap Drills 70%min Ex-VAT Delivered China RMB/kg
|
||||
Minor Metals,Tungsten,"Tungsten Scrap Grinding Swarf W 70%min, Co 5%min Ex-VAT Delivered China RMB/mtu"
|
||||
Minor Metals,Vanadium,Ammonium Metavanadate 98%min EXW China RMB/mt
|
||||
Minor Metals,Vanadium,Vanadium Nitride N 14%min EXW China RMB/mt
|
||||
Minor Metals,Vanadium,Vanadium Pentoxide Flake 92%min CIF China USD/lb V2O5
|
||||
Minor Metals,Vanadium,Vanadium Pentoxide Flake 92%min In port China RMB/mt
|
||||
Minor Metals,Vanadium,Vanadium Pentoxide Flake 98%min CIF China USD/lb V2O5
|
||||
Minor Metals,Vanadium,Vanadium Pentoxide Flake 98%min EXW China RMB/mt
|
||||
Minor Metals,Vanadium,Vanadium Pentoxide Flake 98%min In port China RMB/mt
|
||||
Minor Metals,Vanadium,Vanadium Pentoxide Flake 98%min In warehouse Rotterdam USD/lb V2O5
|
||||
Minor Metals,Vanadium,Vanadium Pentoxide Powder 98%min EXW China RMB/mt
|
||||
Minor Metals,Vanadium,Vanadium Pentoxide Powder 99%min EXW China RMB/mt
|
||||
Minor Metals,Vanadium,Vanadium Pentoxide Powder 99.5%min EXW China RMB/mt
|
||||
Minor Metals,Zirconium,Fused Zirconia Zr(Hf)O2 98.5%min EXW China RMB/mt
|
||||
Minor Metals,Zirconium,Zircon Sand Australian Zr(Hf)O2 66%min In port China RMB/mt
|
||||
Minor Metals,Zirconium,Zircon Sand Australian Zr(Hf)O2 66%min In port China USD/mt
|
||||
Minor Metals,Zirconium,Zircon Sand Indonesian Zr(Hf)O2 66%min CIF China USD/mt
|
||||
Minor Metals,Zirconium,Zircon Sand S.A. Zr(Hf)O2 65%min In port China RMB/mt
|
||||
Minor Metals,Zirconium,Zircon Sand S.A. Zr(Hf)O2 65%min In port China USD/mt
|
||||
Minor Metals,Zirconium,Zircon Sand S.A. Zr(Hf)O2 66%min In port China USD/mt
|
||||
Minor Metals,Zirconium,Zircon Sand Zr(Hf)O2 65%min EXW China RMB/mt
|
||||
Minor Metals,Zirconium,Zirconium Carbonate Zr(Hf)O2 40%min EXW China RMB/mt
|
||||
Minor Metals,Zirconium,"Zirconium Carbonate Zr(Hf)O2 40%min, Cl 50ppm EXW China RMB/mt"
|
||||
Minor Metals,Zirconium,Zirconium Oxychloride Zr(Hf)O2 36%min EXW China RMB/mt
|
||||
Minor Metals,Zirconium,Zirconium Silicate Zr(Hf)O2 64.5%min EXW China RMB/mt
|
||||
Minor Metals,Zirconium,Zirconium Sponge Zr+Hf 99.4%min EXW China RMB/kg
|
||||
Rare Earths,Lutetium,Lutetium Oxide 99.99%min China RMB/kg
|
||||
Rare Earths,Neodymium,"Cerium Carbonate TREO 45%min, CeO2/REO 100% EXW China RMB/mt"
|
||||
Rare Earths,Neodymium,"Cerium Carbonate TREO 45%min, CeO2/REO 100% FOB China USD/mt"
|
||||
Rare Earths,Neodymium,Cerium Metal 99%min EXW China RMB/mt
|
||||
Rare Earths,Neodymium,Cerium Metal 99%min FOB China USD/kg
|
||||
Rare Earths,Neodymium,Cerium Oxide 99%min FOB China USD/mt
|
||||
Rare Earths,Neodymium,Cerium Oxide 99.9%min EXW China RMB/mt
|
||||
Rare Earths,Neodymium,Cerium Oxide 99.9%min In warehouse Rotterdam USD/kg
|
||||
Rare Earths,Neodymium,Cerium Oxide 99.99%min EXW China RMB/mt
|
||||
Rare Earths,Neodymium,"LaCe Carbonate TREO 45%min, CeO2/REO 65±2% EXW China RMB/mt"
|
||||
Rare Earths,Neodymium,"LaCe Chloride TREO 45%min, CeO2/REO 65±2% EXW China RMB/mt"
|
||||
Rare Earths,Neodymium,NdFeB Sintered Rough 35H EXW China RMB/kg
|
||||
Rare Earths,Neodymium,NdFeB Sintered Rough 35M EXW China RMB/kg
|
||||
Rare Earths,Neodymium,NdFeB Sintered Rough 45H EXW China RMB/kg
|
||||
Rare Earths,Neodymium,NdFeB Sintered Rough 45M EXW China RMB/kg
|
||||
Rare Earths,Neodymium,NdFeB Sintered Rough 48H EXW China RMB/kg
|
||||
Rare Earths,Neodymium,NdFeB Sintered Rough 50H EXW China RMB/kg
|
||||
Rare Earths,Neodymium,NdFeB Sintered Rough 50M EXW China RMB/kg
|
||||
Rare Earths,Neodymium,NdFeB Sintered Rough N35 EXW China RMB/kg
|
||||
Rare Earths,Neodymium,NdFeB Sintered Rough N45 EXW China RMB/kg
|
||||
Rare Earths,Neodymium,NdFeB Sintered Rough N52 EXW China RMB/kg
|
||||
Rare Earths,Neodymium,Neodymium Metal 99%min EXW China RMB/mt
|
||||
Rare Earths,Neodymium,Neodymium Metal 99%min FOB China USD/kg
|
||||
Rare Earths,Neodymium,Neodymium Oxide 99.5%min EXW China RMB/mt
|
||||
Rare Earths,Neodymium,Neodymium Oxide 99.5%min FOB China USD/mt
|
||||
Rare Earths,Neodymium,"PrNd Mischmetal Pr 25%, Nd 75% EXW China RMB/mt"
|
||||
Rare Earths,Neodymium,"PrNd Mischmetal Pr 25%, Nd 75% FOB China USD/kg"
|
||||
Rare Earths,Neodymium,"PrNd Oxide Pr6O11 25%, Nd2O3 75% EXW China RMB/mt"
|
||||
Rare Earths,Praseodymium,Praseodymium Metal 99.5%min FOB China USD/kg
|
||||
Rare Earths,Praseodymium,Praseodymium Oxide 99.5%min EXW China RMB/mt
|
||||
Rare Earths,Praseodymium,Praseodymium Oxide 99.5%min FOB China USD/mt
|
||||
Rare Earths,Praseodymium,Praseodymium Oxide 99.5%min In warehouse Rotterdam USD/kg
|
||||
Rare Earths,Samarium,Samarium Cobalt 30H EXW China RMB/kg
|
||||
Rare Earths,Samarium,Samarium Metal 99.5%min EXW China RMB/kg
|
||||
Rare Earths,Samarium,Samarium Metal 99.5%min FOB China USD/kg
|
||||
Rare Earths,Samarium,Samarium Oxide 99.9%min EXW China RMB/mt
|
||||
Rare Earths,Samarium,Samarium Oxide 99.9%min FOB China USD/mt
|
||||
Rare Earths,Samarium,"SmEuGd Oxide TREO 220g/L min, Eu2O3/REO 8%min EXW China RMB/mt"
|
||||
Rare Earths,Scandium,Scandium Aluminum Sc 2% EXW China RMB/kg
|
||||
Rare Earths,Scandium,Scandium Metal 99.99%min EXW China RMB/kg
|
||||
Rare Earths,Scandium,Scandium Metal 99.999%min EXW China RMB/kg
|
||||
Rare Earths,Scandium,Scandium Oxide 99.99%min EXW China RMB/kg
|
||||
Rare Earths,Terbium,Terbium Metal 99.9%min EXW China RMB/kg
|
||||
Rare Earths,Terbium,Terbium Metal 99.9%min FOB China USD/kg
|
||||
Rare Earths,Terbium,Terbium Oxide 99.99%min EXW China RMB/kg
|
||||
Rare Earths,Terbium,Terbium Oxide 99.99%min FOB China USD/kg
|
||||
Rare Earths,Ytterbium,Ytterbium Oxide 99.99%min EXW China RMB/kg
|
||||
Rare Earths,Yttrium,YEu Oxide Eu2O3/REO 6.6%min EXW China RMB/kg
|
||||
Rare Earths,Yttrium,YEu Oxide Eu2O3/REO 6.6%min FOB China USD/kg
|
||||
Rare Earths,Yttrium,Yttrium Metal 99.9%min EXW China RMB/kg
|
||||
Rare Earths,Yttrium,Yttrium Metal 99.9%min FOB China USD/kg
|
||||
Rare Earths,Yttrium,Yttrium Oxide 99.999%min EXW China RMB/mt
|
||||
Rare Earths,Yttrium,Yttrium Oxide 99.999%min FOB China USD/kg
|
||||
Rare Earths,Yttrium,Yttrium Oxide 99.999%min In warehouse Rotterdam USD/kg
|
||||
Refractories,Calcined Bauxite,Calcined Bauxite 85%min 1-3mm EXW China RMB/mt
|
||||
Refractories,Calcined Bauxite,Calcined Bauxite 86%min 1-3mm FOB China USD/mt
|
||||
Refractories,Calcined Bauxite,Calcined Bauxite 87%min 1-3mm EXW China RMB/mt
|
||||
Refractories,Carbon,Calcined Pet Coke S 3%max EXW China RMB/mt
|
||||
Refractories,Carbon,Coal Tar Pitch Coking Value 53%min EXW China RMB/mt
|
||||
Refractories,Carbon,Graphite Electrode H.P. D300mm FOB China USD/mt
|
||||
Refractories,Carbon,Graphite Electrode H.P. D400mm EXW China RMB/mt
|
||||
Refractories,Carbon,Graphite Electrode H.P. D450mm FOB China USD/mt
|
||||
Refractories,Carbon,Graphite Electrode H.P. D500mm EXW China RMB/mt
|
||||
Refractories,Carbon,Graphite Electrode R.P. D400mm EXW China RMB/mt
|
||||
Refractories,Carbon,Graphite Electrode U.H.P. D500mm EXW China RMB/mt
|
||||
Refractories,Carbon,Graphite Electrode U.H.P. D500mm FOB China USD/mt
|
||||
Refractories,Carbon,Graphite Electrode U.H.P. D600mm EXW China RMB/mt
|
||||
Refractories,Carbon,Graphite Electrode U.H.P. D600mm FOB China USD/mt
|
||||
Refractories,Carbon,Pet Coke S 2%max EXW China RMB/mt
|
||||
Refractories,Carbon,Pet Coke S 3%max EXW China RMB/mt
|
||||
Refractories,Carbon,Prebaked Anode S 3%max EXW China RMB/mt
|
||||
Refractories,Carbon,Prebaked Anode S 3%max FOB China USD/mt
|
||||
Refractories,Fused Alumina,Calcined Alumina 99%min 1-3mm US USD/st
|
||||
Refractories,Fused Alumina,Fused Alumina Brown 95%min 1-3mm EXW China RMB/mt
|
||||
Refractories,Fused Alumina,Fused Alumina Brown 95%min 1-3mm EXW India INR/mt
|
||||
Refractories,Fused Alumina,Fused Alumina Brown 95%min 1-3mm FOB China USD/mt
|
||||
Refractories,Fused Alumina,Fused Alumina Brown 95%min 1-3mm In warehouse Rotterdam EUR/mt
|
||||
Refractories,Fused Alumina,Fused Alumina Brown 95%min 1-3mm US USD/st
|
||||
Refractories,Fused Alumina,Fused Alumina Brown 95%min F36 EXW China RMB/mt
|
||||
Refractories,Fused Alumina,Fused Alumina Brown 95%min F36 EXW India INR/mt
|
||||
Refractories,Fused Alumina,Fused Alumina Brown 95%min F36 FOB China USD/mt
|
||||
Refractories,Fused Alumina,Fused Alumina White 99%min 1-3mm EXW China RMB/mt
|
||||
Refractories,Fused Alumina,Fused Alumina White 99%min 1-3mm FOB China USD/mt
|
||||
Refractories,Fused Alumina,Fused Alumina White 99%min 1-3mm In warehouse Rotterdam EUR/mt
|
||||
Refractories,Fused Alumina,Fused Alumina White 99%min 1-3mm US USD/st
|
||||
Refractories,Fused Alumina,Fused Alumina White 99%min F36 EXW China RMB/mt
|
||||
Refractories,Fused Alumina,Fused Alumina White 99%min F36 FOB China USD/mt
|
||||
Refractories,Fused Alumina,Tabular Alumina 99%min 1-3mm US USD/st
|
||||
Refractories,Graphite,Amorphous Graphite 75%min 0-3mm EXW China RMB/mt
|
||||
Refractories,Graphite,Amorphous Graphite 80%min 20-50mm EXW China RMB/mt
|
||||
Refractories,Graphite,Amorphous Graphite 80%min 200mesh min EXW China RMB/mt
|
||||
Refractories,Graphite,Expansible Graphite 99 80 250 EXW China RMB/mt
|
||||
Refractories,Graphite,Flake Graphite +195 EXW China RMB/mt
|
||||
Refractories,Graphite,Flake Graphite +895 EXW China RMB/mt
|
||||
Refractories,Graphite,Flake Graphite -190 EXW China RMB/mt
|
||||
Refractories,Graphite,Flake Graphite -194 EXW China RMB/mt
|
||||
Refractories,Graphite,Flake Graphite -194 FOB China USD/mt
|
||||
Refractories,Graphite,Flake Graphite -199 EXW China RMB/mt
|
||||
Refractories,Graphite,Spherical Graphite 99.95%min 17?m max EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia C.C. 85%min 200mesh EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia C.C. 90%min 200mesh EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia C.C. 90%min 200mesh FOB China USD/mt
|
||||
Refractories,Magnesia,Magnesia C.C. 97%min 0-5mm EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia D.B. 90%min 3-15mm EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia D.B. 90%min 3-15mm FOB China USD/mt
|
||||
Refractories,Magnesia,Magnesia D.B. 90%min 3-15mm In warehouse Rotterdam EUR/mt
|
||||
Refractories,Magnesia,Magnesia D.B. 90%min 3-15mm US USD/st
|
||||
Refractories,Magnesia,Magnesia D.B. 95%min 0-30mm EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia D.B. 95%min 0-30mm FOB China USD/mt
|
||||
Refractories,Magnesia,Magnesia D.B. 95%min 0-30mm In warehouse Rotterdam EUR/mt
|
||||
Refractories,Magnesia,Magnesia D.B. 96.5%min 0-30mm EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia D.B. 97%min 0-30mm EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia D.B. 97%min 0-30mm FOB China USD/mt
|
||||
Refractories,Magnesia,Magnesia E.F. 97%min EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia E.F. 97%min H-Ca EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia E.F. 97%min H-Ca FOB China USD/mt
|
||||
Refractories,Magnesia,Magnesia E.F. 97.5%min Large Crystal EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesia E.F. 97.5%min Large Crystal FOB China USD/mt
|
||||
Refractories,Magnesia,Magnesite Lump 47%min EXW China RMB/mt
|
||||
Refractories,Magnesia,Magnesite Pellet 47%min EXW China RMB/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Black 88%min 0-10mm EXW China RMB/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Black 88%min 0-10mm FOB China USD/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Black 98%min 10-30cm EXW China RMB/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Black 98%min F16-100 EXW China RMB/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Black 98%min F16-100 FOB China USD/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Green 98%min F240 EXW China RMB/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Green 98%min F240 FOB China USD/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Green 98%min JIS2000 EXW China RMB/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Green 98%min JIS2000 FOB China USD/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Green 98.5%min 10-30cm EXW China RMB/mt
|
||||
Refractories,Silicon Carbide,Silicon Carbide Black 88%min 0-10mm In warehouse Rotterdam EUR/mt
|
||||
Stainless & Special,Bearing Steel,Cold Heading Wire 22A ?6.5mm In warehouse Shanghai RMB/mt
|
||||
Stainless & Special,Bearing Steel,Electrical Steel Grain Oriented 130 0.3*980mm In warehouse China RMB/mt
|
||||
Stainless & Special,Bearing Steel,Electrical Steel Non-grain 470 0.5*1200mm In warehouse China RMB/mt
|
||||
Stainless & Special,Bearing Steel,"Electrical Steel Non-grain 800 0.5*1,200mm In warehouse China RMB/mt"
|
||||
Stainless & Special,Bearing Steel,Stainless Bar 321 60mm In warehouse Dainan RMB/mt
|
||||
Stainless & Special,Bearing Steel,Stainless CR Coil 201/2B 1.0mm In warehouse China RMB/mt
|
||||
Stainless & Special,Bearing Steel,Stainless CR Coil 304/2B 1.0mm In warehouse China RMB/mt
|
||||
Stainless & Special,Bearing Steel,Stainless CR Coil 430/2B 1.0mm In warehouse China RMB/mt
|
||||
Stainless & Special,Bearing Steel,Stainless HR Coil 201/No.1 6.0mm In warehouse China RMB/mt
|
||||
Stainless & Special,Bearing Steel,Stainless HR Coil 304/No.1 6.0mm In warehouse China RMB/mt
|
||||
Stainless & Special,Stainless Bar,Stainless Bar 304 60mm In warehouse Dainan RMB/mt
|
||||
Stainless & Special,Stainless Bar,Stainless Bar 321 60mm In warehouse Dainan RMB/mt
|
||||
Stainless & Special,Stainless Pipe,Stainless Seamless Pipe 304 108*4mm In warehouse Wenzhou RMB/mt
|
||||
Stainless & Special,Stainless Scrap,Stainless Scrap 304 Solid In warehouse Tokyo JPY/mt
|
||||
Stainless & Special,Stainless Scrap,Stainless Scrap 304 Solid In warehouse Dainan RMB/mt
|
||||
Stainless & Special,Structural Steel,Stainless CR Coil 304/2B 1.0mm In warehouse China RMB/mt
|
||||
Steel Raw Materials,Coal,"Coking Coal A 10%max, S 0.8%max EXW Hebei RMB/mt"
|
||||
Steel Raw Materials,Coal,"Coking Coal A 10%max, S 0.8%max EXW Henan RMB/mt"
|
||||
Steel Raw Materials,Coal,"Coking Coal A 10.5%max, S 0.8%max EXW Shanxi RMB/mt"
|
||||
Steel Raw Materials,Coal,Thermal Coal Q5000 In port Qinhuangdao RMB/mt
|
||||
Steel Raw Materials,Coal,Thermal Coal Q5500 In port Qinhuangdao RMB/mt
|
||||
Steel Raw Materials,Coke,"Met. Coke A 13%max, S 0.7%max EXW Hebei RMB/mt"
|
||||
Steel Raw Materials,Coke,"Met. Coke A 13%max, S 0.7%max EXW Shandong RMB/mt"
|
||||
Steel Raw Materials,Coke,"Met. Coke A 13%max, S 0.7%max EXW Shanxi RMB/mt"
|
||||
Steel Raw Materials,Iron,Cast Iron Z18-22 EXW China RMB/mt
|
||||
Steel Raw Materials,Iron,Pig Iron L8-10 EXW China RMB/mt
|
||||
Steel Raw Materials,Iron,S.G. Cast Iron Q10-12 EXW China RMB/mt
|
||||
Steel Raw Materials,Iron Ore,Iron Ore Fine 65%min EXW Benxi RMB/dt
|
||||
Steel Raw Materials,Iron Ore,Iron Ore Fine 65%min EXW Laiwu RMB/dt
|
||||
Steel Raw Materials,Iron Ore,Iron Ore Fine 65%min Ex-VAT EXW Daixian RMB/wt
|
||||
Steel Raw Materials,Iron Ore,Iron Ore Fine 66%min Ex-VAT EXW Handan RMB/dt
|
||||
Steel Raw Materials,Iron Ore,Iron Ore Fine Australian 58%min In port China RMB/wt
|
||||
Steel Raw Materials,Iron Ore,Iron Ore Fine Australian 62%min CNF China USD/dt
|
||||
Steel Raw Materials,Iron Ore,Iron Ore Fine Australian 62%min In port China RMB/wt
|
||||
Steel Raw Materials,Iron Ore,Iron Ore Fine Brazilian 65%min In port China RMB/wt
|
||||
Steel Raw Materials,Iron Ore,Iron Ore Lump Australian 62%min In port China RMB/wt
|
||||
Steel Raw Materials,Steel Billet,Steel Billet HRB335 150x150mm EXW China RMB/mt
|
||||
Steel Raw Materials,Steel Billet,Steel Billet Q235 150x150mm EXW China RMB/mt
|
||||
Steel Raw Materials,Steel Scrap,Steel Scrap HMS 4-6mm EXW Shandong RMB/mt
|
||||
Steel Raw Materials,Steel Scrap,Steel Scrap HMS 8mm min EXW Wuxi RMB/mt
|
||||
Steel Raw Materials,Steel Scrap,Steel Scrap MMS 4-6mm EXW Guangzhou RMB/mt
|
||||
Steel Raw Materials,Steel Scrap,Steel Scrap MMS 4-6mm EXW Tianjin RMB/mt
|
||||
Steel Raw Materials,Steel Scrap,Steel Scrap MMS 6-10mm EXW Liaoning RMB/mt
|
||||
Steel Raw Materials,Steel Scrap,Steel Scrap Process Scrap Delivered China RMB/mt
|
||||
|
|
|
@ -0,0 +1,920 @@
|
|||
{
|
||||
"unique_product_names": 674,
|
||||
"catalog_entries": 686,
|
||||
"groups": {
|
||||
"Base Metals": {
|
||||
"Copper": [
|
||||
"Blister Copper TC 98.5%min Delivered China RMB/mt",
|
||||
"Copper Cathode 99.99%min Delivered China RMB/mt",
|
||||
"Copper Conc. 20%min Delivered China RMB/mt Cu",
|
||||
"Copper Conc. TC 25%min CIF China USD/mt",
|
||||
"Copper Rod TC ?8mm EXW China RMB/mt",
|
||||
"Copper Scrap Wire 99%min Delivered China RMB/mt"
|
||||
],
|
||||
"Lead": [
|
||||
"Lead Conc. 60%min EXW China RMB/mt Pb",
|
||||
"Lead Conc. TC 50%min CIF China USD/mt",
|
||||
"Lead Ingot 99.994%min In warehouse Guangzhou RMB/mt",
|
||||
"Lead Ingot 99.994%min In warehouse Shanghai RMB/mt",
|
||||
"Lead Ingot Secondary 98%min EXW China RMB/mt",
|
||||
"Lead Ingot Secondary 98%min Ex-VAT EXW China RMB/mt",
|
||||
"Lead Ingot Secondary 99.994%min EXW China RMB/mt",
|
||||
"Lead Ingot Secondary 99.994%min Ex-VAT EXW China RMB/mt",
|
||||
"Lead Scrap Recycled Deep Cycle Battery Ex-VAT Delivered China RMB/mt",
|
||||
"Lead Scrap Recycled Start-type Battery Ex-VAT Delivered China RMB/mt"
|
||||
],
|
||||
"Nickel": [
|
||||
"Nickel Cathode 99.96%min EXW China RMB/mt",
|
||||
"Nickel Cathode Norilsk 99.96%min In port China RMB/mt",
|
||||
"Nickel Ore Philippine 0.8%min CIF China USD/mt",
|
||||
"Nickel Ore Philippine 0.8%min In port China RMB/mt",
|
||||
"Nickel Ore Philippine 1.3%min CIF China USD/mt",
|
||||
"Nickel Ore Philippine 1.5%min CIF China USD/mt",
|
||||
"Nickel Ore Philippine 1.5%min In port China RMB/mt",
|
||||
"Nickel Ore Philippine 1.8%min CIF China USD/mt",
|
||||
"Nickel Sulfate Ni 22%min; Co 0.05%max Delivered China RMB/mt",
|
||||
"Nickel Sulfate Ni 22%min; Co 0.4%max Delivered China RMB/mt"
|
||||
],
|
||||
"Tin": [
|
||||
"Tin Conc. 60%min Delivered China RMB/mtm",
|
||||
"Tin Conc. Burmese 20%min In warehouse MengA RMB/mtm",
|
||||
"Tin Conc. Burmese 30%min In warehouse MengA RMB/mtm",
|
||||
"Tin Ingot 99.9%min EXW China RMB/mt",
|
||||
"Tin Solder Wire Sn 99%min, Ag 0.3%min, Cu 0.7%max EXW China RMB/kg",
|
||||
"Tin Solder Wire Sn 99%min, Cu 0.7%max EXW China RMB/kg"
|
||||
],
|
||||
"Zinc": [
|
||||
"Zinc Conc. 50%min EXW North China RMB/mt Zn",
|
||||
"Zinc Conc. TC 50% CIF China USD/mt"
|
||||
]
|
||||
},
|
||||
"Carbon Steel": {
|
||||
"Coated": [
|
||||
"HGI Coil DX51D 0.5mm FOB China USD/mt",
|
||||
"HGI Coil DX51D 0.5mm In warehouse Guangzhou RMB/mt",
|
||||
"HGI Coil DX51D 0.5mm In warehouse Shanghai RMB/mt",
|
||||
"HGI Coil DX51D 0.5mm In warehouse Tianjin RMB/mt",
|
||||
"HGI Coil DX51D 1.0mm FOB China USD/mt",
|
||||
"HGI Coil DX51D 1.0mm In warehouse Guangzhou RMB/mt",
|
||||
"HGI Coil DX51D 1.0mm In warehouse Shanghai RMB/mt",
|
||||
"HGI Coil DX51D 1.0mm In warehouse Tianjin RMB/mt",
|
||||
"HGI Coil IS513 0.5mm In warehouse Mumbai INR/mt"
|
||||
],
|
||||
"Cold Rolled Coil": [
|
||||
"CR Coil DC01 1.0mm In warehouse Istanbul USD/mt",
|
||||
"CR Coil IS513 1.0mm In warehouse Mumbai INR/mt",
|
||||
"CR Coil SPCC 0.5mm FOB China USD/mt",
|
||||
"CR Coil SPCC 0.5mm In warehouse Guangzhou RMB/mt",
|
||||
"CR Coil SPCC 0.5mm In warehouse Shanghai RMB/mt",
|
||||
"CR Coil SPCC 0.5mm In warehouse Tianjin RMB/mt",
|
||||
"CR Coil SPCC 1.0mm FOB China USD/mt",
|
||||
"CR Coil SPCC 1.0mm In warehouse Guangzhou RMB/mt",
|
||||
"CR Coil SPCC 1.0mm In warehouse Hanoi VND/mt",
|
||||
"CR Coil SPCC 1.0mm In warehouse Kuala Lumpur MYR/mt",
|
||||
"CR Coil SPCC 1.0mm In warehouse Shanghai RMB/mt",
|
||||
"CR Coil SPCC 1.0mm In warehouse Tianjin RMB/mt",
|
||||
"LaCe Chloride TREO 45%min, CeO2/REO 65±2% EXW China RMB/mt",
|
||||
"LaCe Mischmetal Ce/RE 65%min, Fe 0.3%max EXW China RMB/mt",
|
||||
"Lanthanum Oxide 99.9%min FOB China USD/mt",
|
||||
"Lanthanum Oxide 99.999%min EXW China RMB/mt",
|
||||
"Lanthanum Oxide 99.999%min FOB China USD/kg"
|
||||
],
|
||||
"Hot Rolled Coil": [
|
||||
"Ferro-gadolinium Gd 73% EXW China RMB/mt",
|
||||
"Ferro-holmium 80% EXW China RMB/kg",
|
||||
"Gadolinium Oxide 99.5%min EXW China RMB/mt",
|
||||
"Gadolinium Oxide 99.99%min EXW China RMB/mt",
|
||||
"HR Coil S275 JR 3.0mm In warehouse Istanbul USD/mt",
|
||||
"HR Coil IS2062 3.0mm In warehouse Mumbai INR/mt",
|
||||
"HR Coil Q235B 2.75mm In warehouse Guangzhou RMB/mt",
|
||||
"HR Coil Q235B 2.75mm In warehouse Shanghai RMB/mt",
|
||||
"HR Coil Q235B 2.75mm In warehouse Tianjin RMB/mt",
|
||||
"HR Coil Q235B 5.5mm In warehouse Guangzhou RMB/mt",
|
||||
"HR Coil Q235B 5.5mm In warehouse Shanghai RMB/mt",
|
||||
"HR Coil Q235B 5.5mm In warehouse Tianjin RMB/mt",
|
||||
"HR Coil SS400 2.75mm FOB China USD/mt",
|
||||
"HR Coil SS400 3.0mm In warehouse Bangkok THB/mt",
|
||||
"HR Coil SS400 3.0mm In warehouse Hanoi VND/mt",
|
||||
"HR Coil SS400 3.0mm In warehouse Kuala Lumpur MYR/mt",
|
||||
"HR Coil SS400 5.5mm FOB China USD/mt",
|
||||
"Holmium Oxide 99.5%min EXW China RMB/kg",
|
||||
"Lanthanum Chloride 99.9%min EXW China RMB/mt",
|
||||
"Lanthanum Metal 99%min EXW China RMB/mt",
|
||||
"Lanthanum Metal 99%min FOB China USD/kg",
|
||||
"Lanthanum Oxide 99.9%min EXW China RMB/mt"
|
||||
],
|
||||
"Pipe": [
|
||||
"Seamless Pipe 20# ?219*6mm GB8163 In warehouse Guangzhou RMB/mt",
|
||||
"Seamless Pipe 20# ?219*6mm GB8163 In warehouse Shanghai RMB/mt",
|
||||
"Seamless Pipe 20# ?219*6mm GB8163 In warehouse Tianjin RMB/mt",
|
||||
"Welded Pipe Q235 ?114*3.75mm In warehouse Guangzhou RMB/mt",
|
||||
"Welded Pipe Q235 ?114*3.75mm In warehouse Shanghai RMB/mt",
|
||||
"Welded Pipe Q235 ?114*3.75mm In warehouse Tianjin RMB/mt"
|
||||
],
|
||||
"Plate": [
|
||||
"Medium Plate IS2062 16mm In warehouse Mumbai INR/mt",
|
||||
"Medium Plate Q235B 10mm In warehouse Guangzhou RMB/mt",
|
||||
"Medium Plate Q235B 10mm In warehouse Shanghai RMB/mt",
|
||||
"Medium Plate Q235B 10mm In warehouse Tianjin RMB/mt",
|
||||
"Medium Plate Q235B 16mm In warehouse Guangzhou RMB/mt",
|
||||
"Medium Plate Q235B 16mm In warehouse Shanghai RMB/mt",
|
||||
"Medium Plate Q235B 16mm In warehouse Tianjin RMB/mt",
|
||||
"Medium Plate S275 JR 16mm In warehouse Istanbul USD/mt",
|
||||
"Medium Plate SS400 10mm FOB China USD/mt",
|
||||
"Medium Plate SS400 16mm FOB China USD/mt",
|
||||
"Medium Plate SS400 16mm In warehouse Bangkok THB/mt",
|
||||
"Medium Plate SS400 16mm In warehouse Hanoi VND/mt",
|
||||
"Medium Plate SS400 16mm In warehouse Kuala Lumpur MYR/mt"
|
||||
],
|
||||
"Rebar": [
|
||||
"Rebar BS4449 GR500 12mm In warehouse Kuala Lumpur MYR/mt",
|
||||
"Rebar BS4449 GR500 16mm In warehouse Kuala Lumpur MYR/mt",
|
||||
"Rebar CB400V/SD390 ?14-32mm In warehouse Hanoi VND/mt",
|
||||
"Rebar HRB400 12mm FOB China USD/mt",
|
||||
"Rebar HRB400 20mm FOB China USD/mt",
|
||||
"Rebar HRB400 20mm In warehouse Guangzhou RMB/mt",
|
||||
"Rebar HRB400 20mm In warehouse Shanghai RMB/mt",
|
||||
"Rebar HRB400 20mm In warehouse Tianjin RMB/mt",
|
||||
"TMT Bar Fe500 12mm In warehouse Mumbai INR/mt",
|
||||
"TMT Bar Fe500 16mm In warehouse Mumbai INR/mt"
|
||||
],
|
||||
"Sections": [
|
||||
"Angle Steel Q235 50*50*5mm In warehouse Guangzhou RMB/mt",
|
||||
"Angle Steel Q235 50*50*5mm In warehouse Shanghai RMB/mt",
|
||||
"Angle Steel Q235 50*50*5mm In warehouse Tianjin RMB/mt",
|
||||
"Channel Steel Q235 160*65*8.5mm In warehouse Guangzhou RMB/mt",
|
||||
"Channel Steel Q235 160*65*8.5mm In warehouse Shanghai RMB/mt",
|
||||
"Channel Steel Q235 160*65*8.5mm In warehouse Tianjin RMB/mt",
|
||||
"Dysprosium Metal 99.5%min EXW China RMB/kg",
|
||||
"Dysprosium Metal 99.5%min FOB China USD/kg",
|
||||
"Dysprosium Oxide 99.5%min EXW China RMB/kg",
|
||||
"Dysprosium Oxide 99.5%min FOB China USD/kg",
|
||||
"Erbium Oxide 99.5%min EXW China RMB/kg",
|
||||
"Erbium Oxide 99.5%min FOB China USD/kg",
|
||||
"Europium Metal 99.5%min FOB China USD/kg",
|
||||
"Europium Oxide 99.999%min EXW China RMB/kg",
|
||||
"Europium Oxide 99.999%min FOB China USD/kg",
|
||||
"Ferro-dysprosium 80% EXW China RMB/kg",
|
||||
"Ferro-dysprosium 80% FOB China USD/kg",
|
||||
"H-beam Steel Q235 200*200mm In warehouse Guangzhou RMB/mt",
|
||||
"H-beam Steel Q235 200*200mm In warehouse Shanghai RMB/mt",
|
||||
"H-beam Steel Q235 200*200mm In warehouse Tianjin RMB/mt",
|
||||
"H-beam Steel Q235 300*300mm In warehouse Guangzhou RMB/mt",
|
||||
"H-beam Steel Q235 300*300mm In warehouse Shanghai RMB/mt",
|
||||
"H-beam Steel Q235 300*300mm In warehouse Tianjin RMB/mt",
|
||||
"I-beam Steel Q235 250*118*10mm In warehouse Guangzhou RMB/mt",
|
||||
"I-beam Steel Q235 250*118*10mm In warehouse Shanghai RMB/mt",
|
||||
"I-beam Steel Q235 250*118*10mm In warehouse Tianjin RMB/mt"
|
||||
],
|
||||
"Strip": [
|
||||
"HR Strip Q195 295*2.5mm In warehouse Guangzhou RMB/mt",
|
||||
"HR Strip Q195 295*2.5mm In warehouse Shanghai RMB/mt",
|
||||
"HR Strip Q195 295*2.5mm In warehouse Tianjin RMB/mt",
|
||||
"HR Strip Q195 685*2.75mm In warehouse Guangzhou RMB/mt",
|
||||
"HR Strip Q235B 685*5.5mm In warehouse Shanghai RMB/mt",
|
||||
"HR Strip Q235B 685*5.5mm In warehouse Tianjin RMB/mt"
|
||||
],
|
||||
"Wire Rod": [
|
||||
"LaCe Mischmetal Ce/RE 65%min, Fe 0.3%max EXW China RMB/mt",
|
||||
"LaCe Mischmetal Ce/RE 65%min, Fe 1%max EXW China RMB/mt",
|
||||
"LaCe Mischmetal Ce/RE 65%min, Fe 1%max FOB China USD/kg",
|
||||
"Wire Rod JIS SWRM15 ?6.5mm In warehouse Bangkok THB/mt",
|
||||
"Wire Rod Q195 6.5mm FOB China USD/mt",
|
||||
"Wire Rod Q235 8mm In warehouse Guangzhou RMB/mt",
|
||||
"Wire Rod Q235 8mm In warehouse Shanghai RMB/mt",
|
||||
"Wire Rod Q235 8mm In warehouse Tianjin RMB/mt",
|
||||
"Wire Rod SAE1008 5.5mm In warehouse Kuala Lumpur MYR/mt",
|
||||
"Wire Rod SAE1008 5.5mm In warehouse Mumbai INR/mt",
|
||||
"Wire Rod SAE1008 6.5mm FOB China USD/mt"
|
||||
]
|
||||
},
|
||||
"Ferroalloys": {
|
||||
"Calcium Silicon": [
|
||||
"Calcium-silicon 28-55 EXW China RMB/mt",
|
||||
"Calcium-silicon 28-55 FOB China USD/mt",
|
||||
"Calcium-silicon 30-60 EXW China RMB/mt",
|
||||
"Calcium-silicon 30-60 FOB China USD/mt"
|
||||
],
|
||||
"Chrome Ore": [
|
||||
"Chrome Conc. 40%min FOB S.A. USD/mt",
|
||||
"Chrome Conc. 42%min FOB S.A. USD/mt",
|
||||
"Chrome Conc. 46%min FOB Turkey USD/mt",
|
||||
"Chrome Conc. S.A. 40%min CIF China USD/mt",
|
||||
"Chrome Conc. S.A. 40%min In port China RMB/dmtu",
|
||||
"Chrome Conc. S.A. 42%min CIF China USD/mt",
|
||||
"Chrome Conc. S.A. 42%min In port China RMB/dmtu",
|
||||
"Chrome Conc. Turkish 46%min CIF China USD/mt",
|
||||
"Chrome Conc. Turkish 46%min In port China RMB/dmtu",
|
||||
"Chrome Conc. Zimbabwean 48%min CIF China USD/mt",
|
||||
"Chrome Conc. Zimbabwean 48%min In port China RMB/dmtu",
|
||||
"Chrome Lump 40%min FOB Turkey USD/mt",
|
||||
"Chrome Lump S.A. 38%min CIF China USD/mt",
|
||||
"Chrome Lump S.A. 38%min In port China RMB/dmtu",
|
||||
"Chrome Lump Turkish 40%min CIF China USD/mt",
|
||||
"Chrome Lump Turkish 40%min In port China RMB/dmtu"
|
||||
],
|
||||
"Chromium Silicon": [
|
||||
"Silico-chrome Si 42%min, Cr 30%min EXW China RMB/mt"
|
||||
],
|
||||
"Ferroboron": [
|
||||
"Ferro-boron B 18%min EXW China RMB/mt",
|
||||
"Ferro-boron B 18%min FOB China USD/mt",
|
||||
"Ferro-boron B 19%min EXW China RMB/mt"
|
||||
],
|
||||
"Ferrochrome": [
|
||||
"Ferro-chrome Cr 52%min, C 8%max EXW China RMB/mt 50-basis",
|
||||
"Ferro-chrome Cr 55%min, C 0.1%max EXW China RMB/mt 60-basis",
|
||||
"Ferro-chrome Cr 55%min, C 0.25%max EXW China RMB/mt 60-basis",
|
||||
"Ferro-chrome Cr 60%min, C 0.1%max FOB China USD/lb Cr",
|
||||
"Ferro-chrome Cr 60%min, C 0.1%max In warehouse Pittsburgh USD/lb Cr",
|
||||
"Ferro-chrome Cr 60%min, C 0.1%max In warehouse Rotterdam USD/lb Cr",
|
||||
"Ferro-chrome Cr 60%min, C 8%max In warehouse Pittsburg USD/lb Cr",
|
||||
"Ferro-chrome Cr 60%min, C 8%max In warehouse Rotterdam USD/lb Cr",
|
||||
"Ferro-chrome Cr 60%min, C 8.5%max In warehouse Russia RUB/kg",
|
||||
"Ferro-chrome Cr 65%min, C 0.1%max In warehouse Russia RUB/kg",
|
||||
"Ferro-chrome Indian Cr 58%min, C 8%max CIF China USD/lb Cr",
|
||||
"Ferro-chrome Indian Cr 58%min, C 8%max In port China RMB/mt 50-basis",
|
||||
"Ferro-chrome Kazakhstan Cr 68%min, C 8.5%max CIF China USD/lb Cr",
|
||||
"Ferro-chrome Kazakhstan Cr 68%min, C 8.5%max In port China RMB/mt 50-basis",
|
||||
"Ferro-chrome S.A. Cr 50%min, C 8%max CIF China USD/lb Cr",
|
||||
"Ferro-chrome S.A. Cr 50%min, C 8%max In port China RMB/mt 50-basis"
|
||||
],
|
||||
"Ferromanganese": [
|
||||
"Ferro-manganese Mn 65%min, C 7%max EXW China RMB/mt",
|
||||
"Ferro-manganese Mn 75%min, C 2%max EXW China RMB/mt",
|
||||
"Ferro-manganese Mn 75%min, C 8%max EXW China RMB/mt",
|
||||
"Ferro-manganese Mn 78%min, C 1.5%max EXW China RMB/mt",
|
||||
"Ferro-manganese Mn 78%min, C 7%max In warehouse Russia RUB/kg",
|
||||
"Ferro-manganese Mn 78%min, C 8%max Delivered Europe EUR/mt",
|
||||
"Ferro-manganese Mn 78%min, C 8%max In warehouse Pittsburgh USD/gt",
|
||||
"Ferro-manganese Mn 80%min, C 0.7%max EXW China RMB/mt"
|
||||
],
|
||||
"Ferromolybdenum": [
|
||||
"Ferro-molybdenum 60%min Delivered India INR/kg",
|
||||
"Ferro-molybdenum 60%min EXW China RMB/mt",
|
||||
"Ferro-molybdenum 60%min In port South Korea USD/kg Mo",
|
||||
"Ferro-molybdenum 60%min In warehouse Russia RUB/kg",
|
||||
"Ferro-molybdenum 65%min In warehouse Rotterdam USD/kg Mo",
|
||||
"Ferro-molybdenum 65%min In warehouse US USD/kg Mo",
|
||||
"Molybdenum Oxide 57%min In port South Korea USD/lb",
|
||||
"Molybdenum Oxide 57%min In warehouse Rotterdam USD/lb"
|
||||
],
|
||||
"Ferronickel": [
|
||||
"Ferro-nickel 1.5%min EXW China RMB/mt",
|
||||
"Ferro-nickel 10%min EXW China RMB/mtu",
|
||||
"Nickel Ore Philippine 1.5%min CIF China USD/mt",
|
||||
"Nickel Ore Philippine 1.8%min In port China RMB/mt"
|
||||
],
|
||||
"Ferroniobium": [
|
||||
"Ferro-niobium 50-A EXW China RMB/mt",
|
||||
"Ferro-niobium 60-A EXW China RMB/mt",
|
||||
"Ferro-niobium Brazilian 66% Delivered China RMB/mt",
|
||||
"Ferro-niobium Brazilian 66% In warehouse Rotterdam USD/kg Nb",
|
||||
"Niobium Conc. Nb2O5 50%min, Ta2O5 5%min, CIF China USD/lb Oxides",
|
||||
"Niobium Pentoxide 99.5%min EXW China RMB/kg"
|
||||
],
|
||||
"Ferrophosphorus": [
|
||||
"Ferro-phosphorus 23%min FOB China USD/mt"
|
||||
],
|
||||
"Ferrosilicon": [
|
||||
"Ferro-silicon 65%min FOB Russia USD/mt",
|
||||
"Ferro-silicon 65%min In warehouse Russia RUB/kg",
|
||||
"Ferro-silicon 70%min EXW India INR/mt",
|
||||
"Ferro-silicon 72%min EXW China RMB/mt",
|
||||
"Ferro-silicon 72%min Ex-VAT FOB China USD/mt",
|
||||
"Ferro-silicon 72%min FOB China USD/mt",
|
||||
"Ferro-silicon 75%min EXW China RMB/mt",
|
||||
"Ferro-silicon 75%min Ex-VAT FOB China USD/mt",
|
||||
"Ferro-silicon 75%min FOB China USD/mt",
|
||||
"Ferro-silicon 75%min In warehouse Pittsburgh USD/lb Si",
|
||||
"Ferro-silicon 75%min In warehouse Rotterdam EUR/mt"
|
||||
],
|
||||
"Ferrotitanium": [
|
||||
"Ferro-titanium 30% EXW China RMB/mt 25-basis",
|
||||
"Ferro-titanium 30% EXW India INR/kg",
|
||||
"Ferro-titanium 70% (scrap) EXW China RMB/mt",
|
||||
"Ferro-titanium 70% In port India INR/kg",
|
||||
"Ferro-titanium 70%min Delivered US USD/kg Ti",
|
||||
"Ferro-titanium 70%min EXW Russia RUB/kg",
|
||||
"Ferro-titanium 70%min FOB Russia USD/kg Ti",
|
||||
"Ferro-titanium 70%min In warehouse Rotterdam USD/kg Ti"
|
||||
],
|
||||
"Ferrotungsten": [
|
||||
"Ferro-tungsten 70%min EXW China RMB/mt",
|
||||
"Ferro-tungsten 75%min EXW China RMB/mt",
|
||||
"Ferro-tungsten 75%min FOB China USD/kg W",
|
||||
"Ferro-tungsten 75%min FOB Russia USD/kg W",
|
||||
"Ferro-tungsten 75%min In warehouse Rotterdam USD/kg W"
|
||||
],
|
||||
"Ferrovanadium": [
|
||||
"Ferro-vanadium 50%min CIF China USD/kg V",
|
||||
"Ferro-vanadium 50%min Delivered China RMB/mt",
|
||||
"Ferro-vanadium 50%min In port China RMB/mt",
|
||||
"Ferro-vanadium 50%min In warehouse Russia RUB/kg",
|
||||
"Ferro-vanadium 80%min FOB China USD/kg V",
|
||||
"Ferro-vanadium 80%min In warehouse Pittsburgh USD/kg V",
|
||||
"Ferro-vanadium 80%min In warehouse Rotterdam USD/kg V",
|
||||
"Vanadium Nitride N 14%min EXW China RMB/mt",
|
||||
"Vanadium Pentoxide Flake 98%min In warehouse Rotterdam USD/lb V2O5"
|
||||
],
|
||||
"Manganese Ore": [
|
||||
"Manganese Ore 38%min FOB S.A. USD/dmtu",
|
||||
"Manganese Ore Australian 45%min In Qinzhou Port RMB/mtu",
|
||||
"Manganese Ore Australian 45%min In Tianjin Port RMB/mtu",
|
||||
"Manganese Ore Brazilian 44%min In Qinzhou Port RMB/mtu",
|
||||
"Manganese Ore Brazilian 44%min In Tianjin Port RMB/mtu",
|
||||
"Manganese Ore Carbonate, Mn 13%min EXW China RMB/mtu",
|
||||
"Manganese Ore Gabonese 44%min In Qinzhou Port RMB/mtu",
|
||||
"Manganese Ore Gabonese 44%min In Tianjin Port RMB/mtu",
|
||||
"Manganese Ore S.A. 36%min In Qinzhou Port RMB/mtu",
|
||||
"Manganese Ore S.A. 36%min In Tianjin Port RMB/mtu",
|
||||
"Manganese Ore S.A. 37%min In Qinzhou Port RMB/mtu",
|
||||
"Manganese Ore S.A. 37%min In Tianjin Port RMB/mtu"
|
||||
],
|
||||
"Silicomanganese": [
|
||||
"Silico-manganese 60/14 EXW China RMB/mt",
|
||||
"Silico-manganese 60/14 FOB India USD/mt",
|
||||
"Silico-manganese 65/17 Delivered Europe EUR/mt",
|
||||
"Silico-manganese 65/17 EXW China RMB/mt",
|
||||
"Silico-manganese 65/17 FOB India USD/mt",
|
||||
"Silico-manganese 65/17 In warehouse Pittsburgh USD/lb",
|
||||
"Silico-manganese 65/17 In warehouse Russia RUB/kg"
|
||||
]
|
||||
},
|
||||
"Minor Metals": {
|
||||
"Antimony": [
|
||||
"Antimony Conc. 50%min Delivered China RMB/mt Sb",
|
||||
"Antimony Ingot 99.65%min EXW China RMB/mt",
|
||||
"Antimony Ingot 99.65%min In port India INR/kg",
|
||||
"Antimony Ingot 99.65%min In warehouse Baltimore USD/lb",
|
||||
"Antimony Ingot 99.65%min In warehouse Rotterdam USD/mt",
|
||||
"Antimony Ingot 99.85%min EXW China RMB/mt",
|
||||
"Antimony Trioxide 99.5%min Delivered China RMB/mt",
|
||||
"Antimony Trioxide 99.5%min FOB China USD/mt",
|
||||
"Antimony Trioxide 99.5%min In warehouse Baltimore USD/lb",
|
||||
"Antimony Trioxide 99.5%min In warehouse Rotterdam EUR/kg",
|
||||
"Antimony Trioxide 99.8%min Delivered China RMB/mt",
|
||||
"Ethylene Glycol Antimony 57%min Delivered China RMB/mt"
|
||||
],
|
||||
"Arsenic": [
|
||||
"Arsenic Metal 99%min EXW China RMB/mt",
|
||||
"Arsenic Metal 99%min FOB China USD/mt",
|
||||
"Arsenic Trioxide 99%min EXW China RMB/mt",
|
||||
"Arsenic Trioxide 99%min FOB China USD/mt"
|
||||
],
|
||||
"Bismuth": [
|
||||
"Bismuth Ingot 99.99%min Delivered Europe USD/lb",
|
||||
"Bismuth Ingot 99.99%min Delivered India INR/kg",
|
||||
"Bismuth Ingot 99.99%min Delivered US USD/lb",
|
||||
"Bismuth Ingot 99.99%min EXW China RMB/mt",
|
||||
"Bismuth Ingot 99.99%min FOB China USD/lb",
|
||||
"Bismuth Oxide 99.9%min EXW China RMB/mt"
|
||||
],
|
||||
"Cadmium": [
|
||||
"Cadmium Ingot 99.99%min Delivered India INR/kg",
|
||||
"Cadmium Ingot 99.99%min EXW China RMB/mt",
|
||||
"Cadmium Ingot 99.99%min In warehouse Baltimore USD/lb",
|
||||
"Cadmium Ingot 99.99%min In warehouse Rotterdam USD/lb"
|
||||
],
|
||||
"Calcium": [
|
||||
"Calcium Granule Electrolytic 98.5%min EXW China RMB/mt",
|
||||
"Calcium Granule Electrolytic 98.5%min FOB China USD/mt",
|
||||
"Calcium Granule Reductive 98%min EXW China RMB/mt",
|
||||
"Calcium Granule Reductive 98%min FOB China USD/mt",
|
||||
"Calcium Ingot Reductive 98%min EXW China RMB/mt",
|
||||
"Fluorspar 97%min Delivered China RMB/mt"
|
||||
],
|
||||
"Chromium": [
|
||||
"Chromic Anhydride 99.8%min EXW China RMB/mt",
|
||||
"Chromium Metal 99%min EXW China RMB/mt",
|
||||
"Chromium Metal 99%min FOB China USD/mt",
|
||||
"Chromium Oxide Green 99%min EXW China RMB/mt"
|
||||
],
|
||||
"Cobalt": [
|
||||
"Cobalt Carbonate 46%min Delivered China RMB/mt",
|
||||
"Cobalt Chloride 24%min Delivered China RMB/mt",
|
||||
"Cobalt Conc. 6-8% CIF China USD/lb Co",
|
||||
"Cobalt Intermediate 20-30% CIF China USD/lb Co",
|
||||
"Cobalt Metal 99.8%min Delivered China RMB/kg",
|
||||
"Cobalt Metal 99.8%min In warehouse Baltimore USD/lb",
|
||||
"Cobalt Metal 99.8%min In warehouse Rotterdam USD/lb",
|
||||
"Cobalt Monoxide 78%min Delivered China RMB/kg",
|
||||
"Cobalt Oxide 72%min Delivered China RMB/kg",
|
||||
"Cobalt Oxide 72%min In warehouse Rotterdam EUR/kg",
|
||||
"Cobalt Powder 99.5%min Delivered China RMB/kg",
|
||||
"Cobalt Sulfate 20.5%min Delivered China RMB/mt",
|
||||
"Cobalt Tetroxide 73.5%min Delivered China RMB/kg",
|
||||
"Lithium Cobaltate Co 60%min Delivered China RMB/kg",
|
||||
"NCM 523 Delivered China RMB/mt",
|
||||
"NCM 622 Delivered China RMB/mt",
|
||||
"Samarium Cobalt 30H EXW China RMB/kg"
|
||||
],
|
||||
"Gallium": [
|
||||
"Gallium Metal 99.99%min EXW China RMB/kg",
|
||||
"Gallium Metal 99.99%min FOB China USD/kg",
|
||||
"Gallium Metal 99.99999%min EXW China RMB/kg"
|
||||
],
|
||||
"Germanium": [
|
||||
"Germanium Dioxide 99.99%min Delivered US USD/kg",
|
||||
"Germanium Dioxide 99.99%min EXW China RMB/kg",
|
||||
"Germanium Dioxide 99.99%min FOB China USD/kg",
|
||||
"Germanium Metal 99.99%min Delivered Europe USD/kg",
|
||||
"Germanium Metal 99.99%min Delivered US USD/kg",
|
||||
"Germanium Metal 99.99%min EXW China RMB/kg",
|
||||
"Germanium Metal 99.99%min FOB China USD/kg"
|
||||
],
|
||||
"Indium": [
|
||||
"Indium Ingot 99.995%min Delivered Europe USD/kg",
|
||||
"Indium Ingot 99.995%min Delivered US USD/kg",
|
||||
"Indium Ingot 99.995%min EXW China RMB/kg",
|
||||
"Indium Ingot 99.995%min FOB China USD/kg"
|
||||
],
|
||||
"Lithium": [
|
||||
"LNCMO 523 Delivered China RMB/kg",
|
||||
"LNCMO 622 Delivered China RMB/kg",
|
||||
"Lithium Carbonate 99%min Delivered China RMB/mt",
|
||||
"Lithium Carbonate 99.5%min CIF China USD/kg",
|
||||
"Lithium Carbonate 99.5%min Delivered China RMB/mt",
|
||||
"Lithium Carbonate 99.5%min Delivered EU USD/kg",
|
||||
"Lithium Carbonate 99.5%min Delivered US USD/kg",
|
||||
"Lithium Carbonate 99.5%min FOB South America USD/kg",
|
||||
"Lithium Chloride 99.3%min Delivered China RMB/mt",
|
||||
"Lithium Hydroxide Monohydrate LiOH 56.5%min Delivered China RMB/mt",
|
||||
"Lithium Hydroxide Monohydrate LiOH 56.5%min, Magnets 0.0001%max Delivered China RMB/mt",
|
||||
"Lithium Hydroxide Monohydrate LiOH 56.5%min, Magnets 0.0001%max Delivered South Korea USD/kg",
|
||||
"Lithium Hydroxide Monohydrate LiOH 56.5%min, Magnets 0.0001%max FOB China USD/kg",
|
||||
"Lithium Iron Phosphate Li 3.9%min Delivered China RMB/mt",
|
||||
"Lithium Manganate Mn 58% Delivered China RMB/mt",
|
||||
"Lithium Metal 99%min Delivered China RMB/mt",
|
||||
"Lithium Metal 99.9%min Delivered China RMB/mt",
|
||||
"Spodumene Li2O 6%min CIF China USD/mt"
|
||||
],
|
||||
"Magnesium": [
|
||||
"Magnesium Alloy AM50A EXW China RMB/mt",
|
||||
"Magnesium Alloy AM50A FOB China USD/mt",
|
||||
"Magnesium Alloy AM60B EXW China RMB/mt",
|
||||
"Magnesium Alloy AM60B FOB China USD/mt",
|
||||
"Magnesium Alloy AZ91D EXW China RMB/mt",
|
||||
"Magnesium Alloy AZ91D FOB China USD/mt",
|
||||
"Magnesium Ingot 99.9%min EXW China RMB/mt",
|
||||
"Magnesium Ingot 99.9%min Ex-VAT FOB China USD/mt",
|
||||
"Magnesium Ingot 99.9%min FOB China USD/mt",
|
||||
"Magnesium Ingot 99.9%min In port India INR/kg",
|
||||
"Magnesium Ingot 99.9%min In warehouse Rotterdam USD/mt",
|
||||
"Magnesium Ingot 99.9%min in warehouse Russia RUB/kg",
|
||||
"Magnesium Ingot 99.95%min EXW China RMB/mt",
|
||||
"Magnesium Powder 99.9%min EXW China RMB/mt",
|
||||
"Magnesium Powder 99.9%min FOB China USD/mt"
|
||||
],
|
||||
"Manganese": [
|
||||
"Manganese Briquette 97%min FOB China USD/mt",
|
||||
"Manganese Dioxide Alkaline 91%min EXW China RMB/mt",
|
||||
"Manganese Dioxide Carbon 91%min EXW China RMB/mt",
|
||||
"Manganese Flake 99.7%min CIF India USD/mt",
|
||||
"Manganese Flake 99.7%min EXW China RMB/mt",
|
||||
"Manganese Flake 99.7%min Ex-VAT FOB China USD/mt",
|
||||
"Manganese Flake 99.7%min FOB China USD/mt",
|
||||
"Manganese Flake 99.7%min In warehouse Rotterdam USD/mt",
|
||||
"Manganese Lump 96.5%min FOB China USD/mt",
|
||||
"Manganese Lump 96.5%min In warehouse Russia RUB/kg",
|
||||
"Manganese Sulfate Mn 32%min EXW China RMB/t"
|
||||
],
|
||||
"Mercury": [
|
||||
"Mercury Metal 99.9%min Ex-VAT EXW China RMB/kg",
|
||||
"Mercury Metal 99.999%min Ex-VAT EXW China RMB/kg"
|
||||
],
|
||||
"Molybdenum": [
|
||||
"Ammonium Heptamolybdate 54%min EXW China RMB/mt",
|
||||
"Ammonium Tetrathiomolybdate 56%min EXW China RMB/mt",
|
||||
"Molybdenum Bar 99.9%min EXW China RMB/kg",
|
||||
"Molybdenum Conc. 45%min EXW China RMB/mtu",
|
||||
"Molybdenum Oxide 57%min In port India USD/lb",
|
||||
"Molybdenum Oxide 50%min EXW China RMB/mtu",
|
||||
"Molybdenum Oxide 57%min CIF China USD/lb Mo",
|
||||
"Molybdenum Oxide 57%min In port South Korea USD/lb",
|
||||
"Molybdenum Oxide 57%min In warehouse Rotterdam USD/lb",
|
||||
"Molybdenum Oxide 99.9%min EXW China RMB/mt",
|
||||
"Molybdenum Powder 99.95%min EXW China RMB/kg",
|
||||
"Molybdenum Scrap Cutting Scrap 99.95%min Ex-VAT EXW China RMB/kg",
|
||||
"Molybdenum Scrap Wire Scrap 99.95%min Ex-VAT EXW China RMB/kg",
|
||||
"Sodium Molybdate 99%min EXW China RMB/mt"
|
||||
],
|
||||
"Niobium": [
|
||||
"Niobium Conc. Nb2O5 50%min, Ta2O5 5%min, CIF China USD/lb Oxides",
|
||||
"Niobium Pentoxide 99.5%min EXW China RMB/kg",
|
||||
"Niobium Pentoxide 99.5%min FOB China USD/kg",
|
||||
"Niobium Pentoxide 99.99%min EXW China RMB/kg",
|
||||
"Niobium Pentoxide 99.99%min FOB China USD/kg"
|
||||
],
|
||||
"Rhenium": [
|
||||
"Rhenium APR 99.99%min EXW China RMB/kg"
|
||||
],
|
||||
"Selenium": [
|
||||
"Selenium Dioxide 98%min Delivered China RMB/kg",
|
||||
"Selenium Powder 99.5%min In warehouse Rotterdam USD/lb",
|
||||
"Selenium Powder 99.9%min CIF China USD/lb",
|
||||
"Selenium Powder 99.9%min Delivered China RMB/kg",
|
||||
"Selenium Powder 99.9%min Delivered India INR/kg",
|
||||
"Selenium Powder 99.9%min Delivered US USD/lb",
|
||||
"Selenium Powder 99.9%min FOB Russia USD/kg",
|
||||
"Selenium Powder 99.95%min Delivered China RMB/kg"
|
||||
],
|
||||
"Silicon": [
|
||||
"Polysilicon 9N Delivered China RMB/kg",
|
||||
"Silicon Metal 2-2-02 Delivered China RMB/mt",
|
||||
"Silicon Metal 2-2-02 Ex-VAT FOB China USD/mt",
|
||||
"Silicon Metal 2-2-02 FOB China USD/mt",
|
||||
"Silicon Metal 3-3-03 Delivered China RMB/mt",
|
||||
"Silicon Metal 3-3-03 Ex-VAT FOB China USD/mt",
|
||||
"Silicon Metal 3-3-03 FOB China USD/mt",
|
||||
"Silicon Metal 3-3-03 P 50ppm max Delivered China RMB/mt",
|
||||
"Silicon Metal 3-3-03 P 50ppm max Ex-VAT FOB China USD/mt",
|
||||
"Silicon Metal 3-3-03 P 50ppm max FOB China USD/mt",
|
||||
"Silicon Metal 4-2-1 Delivered China RMB/mt",
|
||||
"Silicon Metal 4-4-1 Delivered China RMB/mt",
|
||||
"Silicon Metal 4-4-1 Delivered Europe EUR/mt",
|
||||
"Silicon Metal 4-4-1 Ex-VAT FOB China USD/mt",
|
||||
"Silicon Metal 4-4-1 FOB China USD/mt",
|
||||
"Silicon Metal 4-4-1 In port India INR/kg",
|
||||
"Silicon Metal 5-5-3 Delivered China RMB/mt",
|
||||
"Silicon Metal 5-5-3 Delivered Europe EUR/mt",
|
||||
"Silicon Metal 5-5-3 Ex-VAT FOB China USD/mt",
|
||||
"Silicon Metal 5-5-3 FOB China USD/mt",
|
||||
"Silicon Metal 5-5-3 In port India INR/kg",
|
||||
"Silicon Metal 98.5%min Delivered US USD/lb",
|
||||
"Silicon Powder 99%min Delivered China RMB/mt"
|
||||
],
|
||||
"Strontium": [
|
||||
"Strontium Metal 99%min EXW China RMB/mt",
|
||||
"Strontium Metal 99%min FOB China USD/mt"
|
||||
],
|
||||
"Tantalum": [
|
||||
"Tantalum Conc. Ta2O5 30%min CIF China USD/lb Ta2O5",
|
||||
"Tantalum Conc. Ta2O5 30%min in warehouse Rotterdam USD/lb Ta2O5",
|
||||
"Tantalum Metal 99.95%min Delivered US USD/kg",
|
||||
"Tantalum Metal 99.95%min EXW China RMB/kg",
|
||||
"Tantalum Metal 99.95%min FOB China USD/kg",
|
||||
"Tantalum Pentoxide 99.5%min EXW China RMB/kg",
|
||||
"Tantalum Pentoxide 99.5%min FOB China USD/kg",
|
||||
"Tantalum Pentoxide 99.99%min EXW China RMB/kg",
|
||||
"Tantalum Pentoxide 99.99%min FOB China USD/kg"
|
||||
],
|
||||
"Tellurium": [
|
||||
"Tellurium Metal 99.9%min In warehouse Rotterdam USD/kg",
|
||||
"Tellurium Metal 99.99%min EXW China RMB/kg",
|
||||
"Tellurium Metal 99.99%min In warehouse Rotterdam USD/kg"
|
||||
],
|
||||
"Titanium": [
|
||||
"Ilmenite Conc Australian TiO2 55-58%min, Fe 27%min CIF China USD/mt",
|
||||
"Ilmenite Conc Kenyan TiO2 48%min, Fe2O3 20%max CIF China USD/mt",
|
||||
"Ilmenite Conc Mozambican TiO2 52%min, Fe2O3 25%max CIF China USD/mt",
|
||||
"Ilmenite Conc TiO2 46%min; Fe2O3 8%max; Ex-VAT EXW China RMB/mt",
|
||||
"Ilmenite Conc TiO2 50%min; Fe 30%min; In port China RMB/mt",
|
||||
"Ilmenite Conc TiO2 50%min; Fe2O3 14%max; In port China RMB/mt",
|
||||
"Ilmenite Conc TiO2 52%min, Fe2O3 25%max FOB Vietnam USD/mt",
|
||||
"Reduced Ilmenite TiO2 56%min EXW China RMB/mt",
|
||||
"Rutile Conc TiO2 95%min In port China RMB/mt",
|
||||
"Titanium Dioxide A TiO2 98%min Delivered China RMB/mt",
|
||||
"Titanium Dioxide R TiO2 93%min Delivered China RMB/mt",
|
||||
"Titanium Dioxide R TiO2 93%min FOB China USD/mt",
|
||||
"Titanium Pipe TA2 25.4x1mm EXW China RMB/kg",
|
||||
"Titanium Plate TA2 2mm EXW China RMB/kg",
|
||||
"Titanium Slag TiO2 74%min EXW China RMB/mt",
|
||||
"Titanium Slag TiO2 90%min EXW China RMB/mt",
|
||||
"Titanium Sponge 99.7%min EXW China RMB/mt",
|
||||
"Titanium Tetrachloride 99.99%min EXW China RMB/mt"
|
||||
],
|
||||
"Tungsten": [
|
||||
"Sodium Tungstate WO3 68%min EXW China RMB/mt",
|
||||
"Thorium Tungsten Electrode ThO2 1.7-2.2% EXW China RMB/kg",
|
||||
"Tungsten APT 88.5%min EXW China RMB/mt",
|
||||
"Tungsten APT 88.5%min FOB China USD/mtu",
|
||||
"Tungsten APT 88.5%min In warehouse Rotterdam USD/mtu",
|
||||
"Tungsten Bar W-4 99.9%min EXW China RMB/kg",
|
||||
"Tungsten Bar W-4 99.9%min FOB China USD/kg",
|
||||
"Tungsten Carbide 99.8%min 2.5-7.0?m EXW China RMB/kg",
|
||||
"Tungsten Carbide 99.8%min 2.5-7.0?m FOB China USD/kg",
|
||||
"Tungsten Ore WO3 50%min FOB Africa USD/mtu",
|
||||
"Tungsten Ore WO3 65%min EXW China RMB/mt",
|
||||
"Tungsten Oxide WO3 99.95%min EXW China RMB/mt",
|
||||
"Tungsten Oxide WO3 99.95%min FOB China USD/mtu",
|
||||
"Tungsten Powder 99.95%min 2.5-7.0?m EXW China RMB/kg",
|
||||
"Tungsten Scrap Anvil 90%min Ex-VAT Delivered China RMB/kg",
|
||||
"Tungsten Scrap CNC Cutting Blades 70%min Ex-VAT Delivered China RMB/kg",
|
||||
"Tungsten Scrap Drills 70%min Ex-VAT Delivered China RMB/kg",
|
||||
"Tungsten Scrap Grinding Swarf W 70%min, Co 5%min Ex-VAT Delivered China RMB/mtu"
|
||||
],
|
||||
"Vanadium": [
|
||||
"Ammonium Metavanadate 98%min EXW China RMB/mt",
|
||||
"Vanadium Nitride N 14%min EXW China RMB/mt",
|
||||
"Vanadium Pentoxide Flake 92%min CIF China USD/lb V2O5",
|
||||
"Vanadium Pentoxide Flake 92%min In port China RMB/mt",
|
||||
"Vanadium Pentoxide Flake 98%min CIF China USD/lb V2O5",
|
||||
"Vanadium Pentoxide Flake 98%min EXW China RMB/mt",
|
||||
"Vanadium Pentoxide Flake 98%min In port China RMB/mt",
|
||||
"Vanadium Pentoxide Flake 98%min In warehouse Rotterdam USD/lb V2O5",
|
||||
"Vanadium Pentoxide Powder 98%min EXW China RMB/mt",
|
||||
"Vanadium Pentoxide Powder 99%min EXW China RMB/mt",
|
||||
"Vanadium Pentoxide Powder 99.5%min EXW China RMB/mt"
|
||||
],
|
||||
"Zirconium": [
|
||||
"Fused Zirconia Zr(Hf)O2 98.5%min EXW China RMB/mt",
|
||||
"Zircon Sand Australian Zr(Hf)O2 66%min In port China RMB/mt",
|
||||
"Zircon Sand Australian Zr(Hf)O2 66%min In port China USD/mt",
|
||||
"Zircon Sand Indonesian Zr(Hf)O2 66%min CIF China USD/mt",
|
||||
"Zircon Sand S.A. Zr(Hf)O2 65%min In port China RMB/mt",
|
||||
"Zircon Sand S.A. Zr(Hf)O2 65%min In port China USD/mt",
|
||||
"Zircon Sand S.A. Zr(Hf)O2 66%min In port China USD/mt",
|
||||
"Zircon Sand Zr(Hf)O2 65%min EXW China RMB/mt",
|
||||
"Zirconium Carbonate Zr(Hf)O2 40%min EXW China RMB/mt",
|
||||
"Zirconium Carbonate Zr(Hf)O2 40%min, Cl 50ppm EXW China RMB/mt",
|
||||
"Zirconium Oxychloride Zr(Hf)O2 36%min EXW China RMB/mt",
|
||||
"Zirconium Silicate Zr(Hf)O2 64.5%min EXW China RMB/mt",
|
||||
"Zirconium Sponge Zr+Hf 99.4%min EXW China RMB/kg"
|
||||
]
|
||||
},
|
||||
"Rare Earths": {
|
||||
"Lutetium": [
|
||||
"Lutetium Oxide 99.99%min China RMB/kg"
|
||||
],
|
||||
"Neodymium": [
|
||||
"Cerium Carbonate TREO 45%min, CeO2/REO 100% EXW China RMB/mt",
|
||||
"Cerium Carbonate TREO 45%min, CeO2/REO 100% FOB China USD/mt",
|
||||
"Cerium Metal 99%min EXW China RMB/mt",
|
||||
"Cerium Metal 99%min FOB China USD/kg",
|
||||
"Cerium Oxide 99%min FOB China USD/mt",
|
||||
"Cerium Oxide 99.9%min EXW China RMB/mt",
|
||||
"Cerium Oxide 99.9%min In warehouse Rotterdam USD/kg",
|
||||
"Cerium Oxide 99.99%min EXW China RMB/mt",
|
||||
"LaCe Carbonate TREO 45%min, CeO2/REO 65±2% EXW China RMB/mt",
|
||||
"LaCe Chloride TREO 45%min, CeO2/REO 65±2% EXW China RMB/mt",
|
||||
"NdFeB Sintered Rough 35H EXW China RMB/kg",
|
||||
"NdFeB Sintered Rough 35M EXW China RMB/kg",
|
||||
"NdFeB Sintered Rough 45H EXW China RMB/kg",
|
||||
"NdFeB Sintered Rough 45M EXW China RMB/kg",
|
||||
"NdFeB Sintered Rough 48H EXW China RMB/kg",
|
||||
"NdFeB Sintered Rough 50H EXW China RMB/kg",
|
||||
"NdFeB Sintered Rough 50M EXW China RMB/kg",
|
||||
"NdFeB Sintered Rough N35 EXW China RMB/kg",
|
||||
"NdFeB Sintered Rough N45 EXW China RMB/kg",
|
||||
"NdFeB Sintered Rough N52 EXW China RMB/kg",
|
||||
"Neodymium Metal 99%min EXW China RMB/mt",
|
||||
"Neodymium Metal 99%min FOB China USD/kg",
|
||||
"Neodymium Oxide 99.5%min EXW China RMB/mt",
|
||||
"Neodymium Oxide 99.5%min FOB China USD/mt",
|
||||
"PrNd Mischmetal Pr 25%, Nd 75% EXW China RMB/mt",
|
||||
"PrNd Mischmetal Pr 25%, Nd 75% FOB China USD/kg",
|
||||
"PrNd Oxide Pr6O11 25%, Nd2O3 75% EXW China RMB/mt"
|
||||
],
|
||||
"Praseodymium": [
|
||||
"Praseodymium Metal 99.5%min FOB China USD/kg",
|
||||
"Praseodymium Oxide 99.5%min EXW China RMB/mt",
|
||||
"Praseodymium Oxide 99.5%min FOB China USD/mt",
|
||||
"Praseodymium Oxide 99.5%min In warehouse Rotterdam USD/kg"
|
||||
],
|
||||
"Samarium": [
|
||||
"Samarium Cobalt 30H EXW China RMB/kg",
|
||||
"Samarium Metal 99.5%min EXW China RMB/kg",
|
||||
"Samarium Metal 99.5%min FOB China USD/kg",
|
||||
"Samarium Oxide 99.9%min EXW China RMB/mt",
|
||||
"Samarium Oxide 99.9%min FOB China USD/mt",
|
||||
"SmEuGd Oxide TREO 220g/L min, Eu2O3/REO 8%min EXW China RMB/mt"
|
||||
],
|
||||
"Scandium": [
|
||||
"Scandium Aluminum Sc 2% EXW China RMB/kg",
|
||||
"Scandium Metal 99.99%min EXW China RMB/kg",
|
||||
"Scandium Metal 99.999%min EXW China RMB/kg",
|
||||
"Scandium Oxide 99.99%min EXW China RMB/kg"
|
||||
],
|
||||
"Terbium": [
|
||||
"Terbium Metal 99.9%min EXW China RMB/kg",
|
||||
"Terbium Metal 99.9%min FOB China USD/kg",
|
||||
"Terbium Oxide 99.99%min EXW China RMB/kg",
|
||||
"Terbium Oxide 99.99%min FOB China USD/kg"
|
||||
],
|
||||
"Ytterbium": [
|
||||
"Ytterbium Oxide 99.99%min EXW China RMB/kg"
|
||||
],
|
||||
"Yttrium": [
|
||||
"YEu Oxide Eu2O3/REO 6.6%min EXW China RMB/kg",
|
||||
"YEu Oxide Eu2O3/REO 6.6%min FOB China USD/kg",
|
||||
"Yttrium Metal 99.9%min EXW China RMB/kg",
|
||||
"Yttrium Metal 99.9%min FOB China USD/kg",
|
||||
"Yttrium Oxide 99.999%min EXW China RMB/mt",
|
||||
"Yttrium Oxide 99.999%min FOB China USD/kg",
|
||||
"Yttrium Oxide 99.999%min In warehouse Rotterdam USD/kg"
|
||||
]
|
||||
},
|
||||
"Refractories": {
|
||||
"Calcined Bauxite": [
|
||||
"Calcined Bauxite 85%min 1-3mm EXW China RMB/mt",
|
||||
"Calcined Bauxite 86%min 1-3mm FOB China USD/mt",
|
||||
"Calcined Bauxite 87%min 1-3mm EXW China RMB/mt"
|
||||
],
|
||||
"Carbon": [
|
||||
"Calcined Pet Coke S 3%max EXW China RMB/mt",
|
||||
"Coal Tar Pitch Coking Value 53%min EXW China RMB/mt",
|
||||
"Graphite Electrode H.P. D300mm FOB China USD/mt",
|
||||
"Graphite Electrode H.P. D400mm EXW China RMB/mt",
|
||||
"Graphite Electrode H.P. D450mm FOB China USD/mt",
|
||||
"Graphite Electrode H.P. D500mm EXW China RMB/mt",
|
||||
"Graphite Electrode R.P. D400mm EXW China RMB/mt",
|
||||
"Graphite Electrode U.H.P. D500mm EXW China RMB/mt",
|
||||
"Graphite Electrode U.H.P. D500mm FOB China USD/mt",
|
||||
"Graphite Electrode U.H.P. D600mm EXW China RMB/mt",
|
||||
"Graphite Electrode U.H.P. D600mm FOB China USD/mt",
|
||||
"Pet Coke S 2%max EXW China RMB/mt",
|
||||
"Pet Coke S 3%max EXW China RMB/mt",
|
||||
"Prebaked Anode S 3%max EXW China RMB/mt",
|
||||
"Prebaked Anode S 3%max FOB China USD/mt"
|
||||
],
|
||||
"Fused Alumina": [
|
||||
"Calcined Alumina 99%min 1-3mm US USD/st",
|
||||
"Fused Alumina Brown 95%min 1-3mm EXW China RMB/mt",
|
||||
"Fused Alumina Brown 95%min 1-3mm EXW India INR/mt",
|
||||
"Fused Alumina Brown 95%min 1-3mm FOB China USD/mt",
|
||||
"Fused Alumina Brown 95%min 1-3mm In warehouse Rotterdam EUR/mt",
|
||||
"Fused Alumina Brown 95%min 1-3mm US USD/st",
|
||||
"Fused Alumina Brown 95%min F36 EXW China RMB/mt",
|
||||
"Fused Alumina Brown 95%min F36 EXW India INR/mt",
|
||||
"Fused Alumina Brown 95%min F36 FOB China USD/mt",
|
||||
"Fused Alumina White 99%min 1-3mm EXW China RMB/mt",
|
||||
"Fused Alumina White 99%min 1-3mm FOB China USD/mt",
|
||||
"Fused Alumina White 99%min 1-3mm In warehouse Rotterdam EUR/mt",
|
||||
"Fused Alumina White 99%min 1-3mm US USD/st",
|
||||
"Fused Alumina White 99%min F36 EXW China RMB/mt",
|
||||
"Fused Alumina White 99%min F36 FOB China USD/mt",
|
||||
"Tabular Alumina 99%min 1-3mm US USD/st"
|
||||
],
|
||||
"Graphite": [
|
||||
"Amorphous Graphite 75%min 0-3mm EXW China RMB/mt",
|
||||
"Amorphous Graphite 80%min 20-50mm EXW China RMB/mt",
|
||||
"Amorphous Graphite 80%min 200mesh min EXW China RMB/mt",
|
||||
"Expansible Graphite 99 80 250 EXW China RMB/mt",
|
||||
"Flake Graphite +195 EXW China RMB/mt",
|
||||
"Flake Graphite +895 EXW China RMB/mt",
|
||||
"Flake Graphite -190 EXW China RMB/mt",
|
||||
"Flake Graphite -194 EXW China RMB/mt",
|
||||
"Flake Graphite -194 FOB China USD/mt",
|
||||
"Flake Graphite -199 EXW China RMB/mt",
|
||||
"Spherical Graphite 99.95%min 17?m max EXW China RMB/mt"
|
||||
],
|
||||
"Magnesia": [
|
||||
"Magnesia C.C. 85%min 200mesh EXW China RMB/mt",
|
||||
"Magnesia C.C. 90%min 200mesh EXW China RMB/mt",
|
||||
"Magnesia C.C. 90%min 200mesh FOB China USD/mt",
|
||||
"Magnesia C.C. 97%min 0-5mm EXW China RMB/mt",
|
||||
"Magnesia D.B. 90%min 3-15mm EXW China RMB/mt",
|
||||
"Magnesia D.B. 90%min 3-15mm FOB China USD/mt",
|
||||
"Magnesia D.B. 90%min 3-15mm In warehouse Rotterdam EUR/mt",
|
||||
"Magnesia D.B. 90%min 3-15mm US USD/st",
|
||||
"Magnesia D.B. 95%min 0-30mm EXW China RMB/mt",
|
||||
"Magnesia D.B. 95%min 0-30mm FOB China USD/mt",
|
||||
"Magnesia D.B. 95%min 0-30mm In warehouse Rotterdam EUR/mt",
|
||||
"Magnesia D.B. 96.5%min 0-30mm EXW China RMB/mt",
|
||||
"Magnesia D.B. 97%min 0-30mm EXW China RMB/mt",
|
||||
"Magnesia D.B. 97%min 0-30mm FOB China USD/mt",
|
||||
"Magnesia E.F. 97%min EXW China RMB/mt",
|
||||
"Magnesia E.F. 97%min H-Ca EXW China RMB/mt",
|
||||
"Magnesia E.F. 97%min H-Ca FOB China USD/mt",
|
||||
"Magnesia E.F. 97.5%min Large Crystal EXW China RMB/mt",
|
||||
"Magnesia E.F. 97.5%min Large Crystal FOB China USD/mt",
|
||||
"Magnesite Lump 47%min EXW China RMB/mt",
|
||||
"Magnesite Pellet 47%min EXW China RMB/mt"
|
||||
],
|
||||
"Silicon Carbide": [
|
||||
"Silicon Carbide Black 88%min 0-10mm EXW China RMB/mt",
|
||||
"Silicon Carbide Black 88%min 0-10mm FOB China USD/mt",
|
||||
"Silicon Carbide Black 98%min 10-30cm EXW China RMB/mt",
|
||||
"Silicon Carbide Black 98%min F16-100 EXW China RMB/mt",
|
||||
"Silicon Carbide Black 98%min F16-100 FOB China USD/mt",
|
||||
"Silicon Carbide Green 98%min F240 EXW China RMB/mt",
|
||||
"Silicon Carbide Green 98%min F240 FOB China USD/mt",
|
||||
"Silicon Carbide Green 98%min JIS2000 EXW China RMB/mt",
|
||||
"Silicon Carbide Green 98%min JIS2000 FOB China USD/mt",
|
||||
"Silicon Carbide Green 98.5%min 10-30cm EXW China RMB/mt",
|
||||
"Silicon Carbide Black 88%min 0-10mm In warehouse Rotterdam EUR/mt"
|
||||
]
|
||||
},
|
||||
"Stainless & Special": {
|
||||
"Bearing Steel": [
|
||||
"Cold Heading Wire 22A ?6.5mm In warehouse Shanghai RMB/mt",
|
||||
"Electrical Steel Grain Oriented 130 0.3*980mm In warehouse China RMB/mt",
|
||||
"Electrical Steel Non-grain 470 0.5*1200mm In warehouse China RMB/mt",
|
||||
"Electrical Steel Non-grain 800 0.5*1,200mm In warehouse China RMB/mt",
|
||||
"Stainless Bar 321 60mm In warehouse Dainan RMB/mt",
|
||||
"Stainless CR Coil 201/2B 1.0mm In warehouse China RMB/mt",
|
||||
"Stainless CR Coil 304/2B 1.0mm In warehouse China RMB/mt",
|
||||
"Stainless CR Coil 430/2B 1.0mm In warehouse China RMB/mt",
|
||||
"Stainless HR Coil 201/No.1 6.0mm In warehouse China RMB/mt",
|
||||
"Stainless HR Coil 304/No.1 6.0mm In warehouse China RMB/mt"
|
||||
],
|
||||
"Stainless Bar": [
|
||||
"Stainless Bar 304 60mm In warehouse Dainan RMB/mt",
|
||||
"Stainless Bar 321 60mm In warehouse Dainan RMB/mt"
|
||||
],
|
||||
"Stainless Pipe": [
|
||||
"Stainless Seamless Pipe 304 108*4mm In warehouse Wenzhou RMB/mt"
|
||||
],
|
||||
"Stainless Scrap": [
|
||||
"Stainless Scrap 304 Solid In warehouse Tokyo JPY/mt",
|
||||
"Stainless Scrap 304 Solid In warehouse Dainan RMB/mt"
|
||||
],
|
||||
"Structural Steel": [
|
||||
"Stainless CR Coil 304/2B 1.0mm In warehouse China RMB/mt"
|
||||
]
|
||||
},
|
||||
"Steel Raw Materials": {
|
||||
"Coal": [
|
||||
"Coking Coal A 10%max, S 0.8%max EXW Hebei RMB/mt",
|
||||
"Coking Coal A 10%max, S 0.8%max EXW Henan RMB/mt",
|
||||
"Coking Coal A 10.5%max, S 0.8%max EXW Shanxi RMB/mt",
|
||||
"Thermal Coal Q5000 In port Qinhuangdao RMB/mt",
|
||||
"Thermal Coal Q5500 In port Qinhuangdao RMB/mt"
|
||||
],
|
||||
"Coke": [
|
||||
"Met. Coke A 13%max, S 0.7%max EXW Hebei RMB/mt",
|
||||
"Met. Coke A 13%max, S 0.7%max EXW Shandong RMB/mt",
|
||||
"Met. Coke A 13%max, S 0.7%max EXW Shanxi RMB/mt"
|
||||
],
|
||||
"Iron": [
|
||||
"Cast Iron Z18-22 EXW China RMB/mt",
|
||||
"Pig Iron L8-10 EXW China RMB/mt",
|
||||
"S.G. Cast Iron Q10-12 EXW China RMB/mt"
|
||||
],
|
||||
"Iron Ore": [
|
||||
"Iron Ore Fine 65%min EXW Benxi RMB/dt",
|
||||
"Iron Ore Fine 65%min EXW Laiwu RMB/dt",
|
||||
"Iron Ore Fine 65%min Ex-VAT EXW Daixian RMB/wt",
|
||||
"Iron Ore Fine 66%min Ex-VAT EXW Handan RMB/dt",
|
||||
"Iron Ore Fine Australian 58%min In port China RMB/wt",
|
||||
"Iron Ore Fine Australian 62%min CNF China USD/dt",
|
||||
"Iron Ore Fine Australian 62%min In port China RMB/wt",
|
||||
"Iron Ore Fine Brazilian 65%min In port China RMB/wt",
|
||||
"Iron Ore Lump Australian 62%min In port China RMB/wt"
|
||||
],
|
||||
"Steel Billet": [
|
||||
"Steel Billet HRB335 150x150mm EXW China RMB/mt",
|
||||
"Steel Billet Q235 150x150mm EXW China RMB/mt"
|
||||
],
|
||||
"Steel Scrap": [
|
||||
"Steel Scrap HMS 4-6mm EXW Shandong RMB/mt",
|
||||
"Steel Scrap HMS 8mm min EXW Wuxi RMB/mt",
|
||||
"Steel Scrap MMS 4-6mm EXW Guangzhou RMB/mt",
|
||||
"Steel Scrap MMS 4-6mm EXW Tianjin RMB/mt",
|
||||
"Steel Scrap MMS 6-10mm EXW Liaoning RMB/mt",
|
||||
"Steel Scrap Process Scrap Delivered China RMB/mt"
|
||||
]
|
||||
}
|
||||
},
|
||||
"duplicate_title_placements": [
|
||||
{
|
||||
"title": "LaCe Chloride TREO 45%min, CeO2/REO 65±2% EXW China RMB/mt",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "LaCe Mischmetal Ce/RE 65%min, Fe 0.3%max EXW China RMB/mt",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "Molybdenum Oxide 57%min In port South Korea USD/lb",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "Molybdenum Oxide 57%min In warehouse Rotterdam USD/lb",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "Nickel Ore Philippine 1.5%min CIF China USD/mt",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "Niobium Conc. Nb2O5 50%min, Ta2O5 5%min, CIF China USD/lb Oxides",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "Niobium Pentoxide 99.5%min EXW China RMB/kg",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "Samarium Cobalt 30H EXW China RMB/kg",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "Stainless Bar 321 60mm In warehouse Dainan RMB/mt",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "Stainless CR Coil 304/2B 1.0mm In warehouse China RMB/mt",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "Vanadium Nitride N 14%min EXW China RMB/mt",
|
||||
"placements": 2
|
||||
},
|
||||
{
|
||||
"title": "Vanadium Pentoxide Flake 98%min In warehouse Rotterdam USD/lb V2O5",
|
||||
"placements": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,870 @@
|
|||
# Product Catalog
|
||||
|
||||
Unique product names: 674
|
||||
Group/category product entries: 686
|
||||
|
||||
Use /api/v1/metals/tree for this same catalog as JSON.
|
||||
|
||||
## Base Metals (34)
|
||||
|
||||
### Copper (6)
|
||||
- Blister Copper TC 98.5%min Delivered China RMB/mt
|
||||
- Copper Cathode 99.99%min Delivered China RMB/mt
|
||||
- Copper Conc. 20%min Delivered China RMB/mt Cu
|
||||
- Copper Conc. TC 25%min CIF China USD/mt
|
||||
- Copper Rod TC ?8mm EXW China RMB/mt
|
||||
- Copper Scrap Wire 99%min Delivered China RMB/mt
|
||||
|
||||
### Lead (10)
|
||||
- Lead Conc. 60%min EXW China RMB/mt Pb
|
||||
- Lead Conc. TC 50%min CIF China USD/mt
|
||||
- Lead Ingot 99.994%min In warehouse Guangzhou RMB/mt
|
||||
- Lead Ingot 99.994%min In warehouse Shanghai RMB/mt
|
||||
- Lead Ingot Secondary 98%min EXW China RMB/mt
|
||||
- Lead Ingot Secondary 98%min Ex-VAT EXW China RMB/mt
|
||||
- Lead Ingot Secondary 99.994%min EXW China RMB/mt
|
||||
- Lead Ingot Secondary 99.994%min Ex-VAT EXW China RMB/mt
|
||||
- Lead Scrap Recycled Deep Cycle Battery Ex-VAT Delivered China RMB/mt
|
||||
- Lead Scrap Recycled Start-type Battery Ex-VAT Delivered China RMB/mt
|
||||
|
||||
### Nickel (10)
|
||||
- Nickel Cathode 99.96%min EXW China RMB/mt
|
||||
- Nickel Cathode Norilsk 99.96%min In port China RMB/mt
|
||||
- Nickel Ore Philippine 0.8%min CIF China USD/mt
|
||||
- Nickel Ore Philippine 0.8%min In port China RMB/mt
|
||||
- Nickel Ore Philippine 1.3%min CIF China USD/mt
|
||||
- Nickel Ore Philippine 1.5%min CIF China USD/mt
|
||||
- Nickel Ore Philippine 1.5%min In port China RMB/mt
|
||||
- Nickel Ore Philippine 1.8%min CIF China USD/mt
|
||||
- Nickel Sulfate Ni 22%min; Co 0.05%max Delivered China RMB/mt
|
||||
- Nickel Sulfate Ni 22%min; Co 0.4%max Delivered China RMB/mt
|
||||
|
||||
### Tin (6)
|
||||
- Tin Conc. 60%min Delivered China RMB/mtm
|
||||
- Tin Conc. Burmese 20%min In warehouse MengA RMB/mtm
|
||||
- Tin Conc. Burmese 30%min In warehouse MengA RMB/mtm
|
||||
- Tin Ingot 99.9%min EXW China RMB/mt
|
||||
- Tin Solder Wire Sn 99%min, Ag 0.3%min, Cu 0.7%max EXW China RMB/kg
|
||||
- Tin Solder Wire Sn 99%min, Cu 0.7%max EXW China RMB/kg
|
||||
|
||||
### Zinc (2)
|
||||
- Zinc Conc. 50%min EXW North China RMB/mt Zn
|
||||
- Zinc Conc. TC 50% CIF China USD/mt
|
||||
|
||||
## Carbon Steel (120)
|
||||
|
||||
### Coated (9)
|
||||
- HGI Coil DX51D 0.5mm FOB China USD/mt
|
||||
- HGI Coil DX51D 0.5mm In warehouse Guangzhou RMB/mt
|
||||
- HGI Coil DX51D 0.5mm In warehouse Shanghai RMB/mt
|
||||
- HGI Coil DX51D 0.5mm In warehouse Tianjin RMB/mt
|
||||
- HGI Coil DX51D 1.0mm FOB China USD/mt
|
||||
- HGI Coil DX51D 1.0mm In warehouse Guangzhou RMB/mt
|
||||
- HGI Coil DX51D 1.0mm In warehouse Shanghai RMB/mt
|
||||
- HGI Coil DX51D 1.0mm In warehouse Tianjin RMB/mt
|
||||
- HGI Coil IS513 0.5mm In warehouse Mumbai INR/mt
|
||||
|
||||
### Cold Rolled Coil (17)
|
||||
- CR Coil DC01 1.0mm In warehouse Istanbul USD/mt
|
||||
- CR Coil IS513 1.0mm In warehouse Mumbai INR/mt
|
||||
- CR Coil SPCC 0.5mm FOB China USD/mt
|
||||
- CR Coil SPCC 0.5mm In warehouse Guangzhou RMB/mt
|
||||
- CR Coil SPCC 0.5mm In warehouse Shanghai RMB/mt
|
||||
- CR Coil SPCC 0.5mm In warehouse Tianjin RMB/mt
|
||||
- CR Coil SPCC 1.0mm FOB China USD/mt
|
||||
- CR Coil SPCC 1.0mm In warehouse Guangzhou RMB/mt
|
||||
- CR Coil SPCC 1.0mm In warehouse Hanoi VND/mt
|
||||
- CR Coil SPCC 1.0mm In warehouse Kuala Lumpur MYR/mt
|
||||
- CR Coil SPCC 1.0mm In warehouse Shanghai RMB/mt
|
||||
- CR Coil SPCC 1.0mm In warehouse Tianjin RMB/mt
|
||||
- LaCe Chloride TREO 45%min, CeO2/REO 65±2% EXW China RMB/mt
|
||||
- LaCe Mischmetal Ce/RE 65%min, Fe 0.3%max EXW China RMB/mt
|
||||
- Lanthanum Oxide 99.9%min FOB China USD/mt
|
||||
- Lanthanum Oxide 99.999%min EXW China RMB/mt
|
||||
- Lanthanum Oxide 99.999%min FOB China USD/kg
|
||||
|
||||
### Hot Rolled Coil (22)
|
||||
- Ferro-gadolinium Gd 73% EXW China RMB/mt
|
||||
- Ferro-holmium 80% EXW China RMB/kg
|
||||
- Gadolinium Oxide 99.5%min EXW China RMB/mt
|
||||
- Gadolinium Oxide 99.99%min EXW China RMB/mt
|
||||
- HR Coil S275 JR 3.0mm In warehouse Istanbul USD/mt
|
||||
- HR Coil IS2062 3.0mm In warehouse Mumbai INR/mt
|
||||
- HR Coil Q235B 2.75mm In warehouse Guangzhou RMB/mt
|
||||
- HR Coil Q235B 2.75mm In warehouse Shanghai RMB/mt
|
||||
- HR Coil Q235B 2.75mm In warehouse Tianjin RMB/mt
|
||||
- HR Coil Q235B 5.5mm In warehouse Guangzhou RMB/mt
|
||||
- HR Coil Q235B 5.5mm In warehouse Shanghai RMB/mt
|
||||
- HR Coil Q235B 5.5mm In warehouse Tianjin RMB/mt
|
||||
- HR Coil SS400 2.75mm FOB China USD/mt
|
||||
- HR Coil SS400 3.0mm In warehouse Bangkok THB/mt
|
||||
- HR Coil SS400 3.0mm In warehouse Hanoi VND/mt
|
||||
- HR Coil SS400 3.0mm In warehouse Kuala Lumpur MYR/mt
|
||||
- HR Coil SS400 5.5mm FOB China USD/mt
|
||||
- Holmium Oxide 99.5%min EXW China RMB/kg
|
||||
- Lanthanum Chloride 99.9%min EXW China RMB/mt
|
||||
- Lanthanum Metal 99%min EXW China RMB/mt
|
||||
- Lanthanum Metal 99%min FOB China USD/kg
|
||||
- Lanthanum Oxide 99.9%min EXW China RMB/mt
|
||||
|
||||
### Pipe (6)
|
||||
- Seamless Pipe 20# ?219*6mm GB8163 In warehouse Guangzhou RMB/mt
|
||||
- Seamless Pipe 20# ?219*6mm GB8163 In warehouse Shanghai RMB/mt
|
||||
- Seamless Pipe 20# ?219*6mm GB8163 In warehouse Tianjin RMB/mt
|
||||
- Welded Pipe Q235 ?114*3.75mm In warehouse Guangzhou RMB/mt
|
||||
- Welded Pipe Q235 ?114*3.75mm In warehouse Shanghai RMB/mt
|
||||
- Welded Pipe Q235 ?114*3.75mm In warehouse Tianjin RMB/mt
|
||||
|
||||
### Plate (13)
|
||||
- Medium Plate IS2062 16mm In warehouse Mumbai INR/mt
|
||||
- Medium Plate Q235B 10mm In warehouse Guangzhou RMB/mt
|
||||
- Medium Plate Q235B 10mm In warehouse Shanghai RMB/mt
|
||||
- Medium Plate Q235B 10mm In warehouse Tianjin RMB/mt
|
||||
- Medium Plate Q235B 16mm In warehouse Guangzhou RMB/mt
|
||||
- Medium Plate Q235B 16mm In warehouse Shanghai RMB/mt
|
||||
- Medium Plate Q235B 16mm In warehouse Tianjin RMB/mt
|
||||
- Medium Plate S275 JR 16mm In warehouse Istanbul USD/mt
|
||||
- Medium Plate SS400 10mm FOB China USD/mt
|
||||
- Medium Plate SS400 16mm FOB China USD/mt
|
||||
- Medium Plate SS400 16mm In warehouse Bangkok THB/mt
|
||||
- Medium Plate SS400 16mm In warehouse Hanoi VND/mt
|
||||
- Medium Plate SS400 16mm In warehouse Kuala Lumpur MYR/mt
|
||||
|
||||
### Rebar (10)
|
||||
- Rebar BS4449 GR500 12mm In warehouse Kuala Lumpur MYR/mt
|
||||
- Rebar BS4449 GR500 16mm In warehouse Kuala Lumpur MYR/mt
|
||||
- Rebar CB400V/SD390 ?14-32mm In warehouse Hanoi VND/mt
|
||||
- Rebar HRB400 12mm FOB China USD/mt
|
||||
- Rebar HRB400 20mm FOB China USD/mt
|
||||
- Rebar HRB400 20mm In warehouse Guangzhou RMB/mt
|
||||
- Rebar HRB400 20mm In warehouse Shanghai RMB/mt
|
||||
- Rebar HRB400 20mm In warehouse Tianjin RMB/mt
|
||||
- TMT Bar Fe500 12mm In warehouse Mumbai INR/mt
|
||||
- TMT Bar Fe500 16mm In warehouse Mumbai INR/mt
|
||||
|
||||
### Sections (26)
|
||||
- Angle Steel Q235 50*50*5mm In warehouse Guangzhou RMB/mt
|
||||
- Angle Steel Q235 50*50*5mm In warehouse Shanghai RMB/mt
|
||||
- Angle Steel Q235 50*50*5mm In warehouse Tianjin RMB/mt
|
||||
- Channel Steel Q235 160*65*8.5mm In warehouse Guangzhou RMB/mt
|
||||
- Channel Steel Q235 160*65*8.5mm In warehouse Shanghai RMB/mt
|
||||
- Channel Steel Q235 160*65*8.5mm In warehouse Tianjin RMB/mt
|
||||
- Dysprosium Metal 99.5%min EXW China RMB/kg
|
||||
- Dysprosium Metal 99.5%min FOB China USD/kg
|
||||
- Dysprosium Oxide 99.5%min EXW China RMB/kg
|
||||
- Dysprosium Oxide 99.5%min FOB China USD/kg
|
||||
- Erbium Oxide 99.5%min EXW China RMB/kg
|
||||
- Erbium Oxide 99.5%min FOB China USD/kg
|
||||
- Europium Metal 99.5%min FOB China USD/kg
|
||||
- Europium Oxide 99.999%min EXW China RMB/kg
|
||||
- Europium Oxide 99.999%min FOB China USD/kg
|
||||
- Ferro-dysprosium 80% EXW China RMB/kg
|
||||
- Ferro-dysprosium 80% FOB China USD/kg
|
||||
- H-beam Steel Q235 200*200mm In warehouse Guangzhou RMB/mt
|
||||
- H-beam Steel Q235 200*200mm In warehouse Shanghai RMB/mt
|
||||
- H-beam Steel Q235 200*200mm In warehouse Tianjin RMB/mt
|
||||
- H-beam Steel Q235 300*300mm In warehouse Guangzhou RMB/mt
|
||||
- H-beam Steel Q235 300*300mm In warehouse Shanghai RMB/mt
|
||||
- H-beam Steel Q235 300*300mm In warehouse Tianjin RMB/mt
|
||||
- I-beam Steel Q235 250*118*10mm In warehouse Guangzhou RMB/mt
|
||||
- I-beam Steel Q235 250*118*10mm In warehouse Shanghai RMB/mt
|
||||
- I-beam Steel Q235 250*118*10mm In warehouse Tianjin RMB/mt
|
||||
|
||||
### Strip (6)
|
||||
- HR Strip Q195 295*2.5mm In warehouse Guangzhou RMB/mt
|
||||
- HR Strip Q195 295*2.5mm In warehouse Shanghai RMB/mt
|
||||
- HR Strip Q195 295*2.5mm In warehouse Tianjin RMB/mt
|
||||
- HR Strip Q195 685*2.75mm In warehouse Guangzhou RMB/mt
|
||||
- HR Strip Q235B 685*5.5mm In warehouse Shanghai RMB/mt
|
||||
- HR Strip Q235B 685*5.5mm In warehouse Tianjin RMB/mt
|
||||
|
||||
### Wire Rod (11)
|
||||
- LaCe Mischmetal Ce/RE 65%min, Fe 0.3%max EXW China RMB/mt
|
||||
- LaCe Mischmetal Ce/RE 65%min, Fe 1%max EXW China RMB/mt
|
||||
- LaCe Mischmetal Ce/RE 65%min, Fe 1%max FOB China USD/kg
|
||||
- Wire Rod JIS SWRM15 ?6.5mm In warehouse Bangkok THB/mt
|
||||
- Wire Rod Q195 6.5mm FOB China USD/mt
|
||||
- Wire Rod Q235 8mm In warehouse Guangzhou RMB/mt
|
||||
- Wire Rod Q235 8mm In warehouse Shanghai RMB/mt
|
||||
- Wire Rod Q235 8mm In warehouse Tianjin RMB/mt
|
||||
- Wire Rod SAE1008 5.5mm In warehouse Kuala Lumpur MYR/mt
|
||||
- Wire Rod SAE1008 5.5mm In warehouse Mumbai INR/mt
|
||||
- Wire Rod SAE1008 6.5mm FOB China USD/mt
|
||||
|
||||
## Ferroalloys (119)
|
||||
|
||||
### Calcium Silicon (4)
|
||||
- Calcium-silicon 28-55 EXW China RMB/mt
|
||||
- Calcium-silicon 28-55 FOB China USD/mt
|
||||
- Calcium-silicon 30-60 EXW China RMB/mt
|
||||
- Calcium-silicon 30-60 FOB China USD/mt
|
||||
|
||||
### Chrome Ore (16)
|
||||
- Chrome Conc. 40%min FOB S.A. USD/mt
|
||||
- Chrome Conc. 42%min FOB S.A. USD/mt
|
||||
- Chrome Conc. 46%min FOB Turkey USD/mt
|
||||
- Chrome Conc. S.A. 40%min CIF China USD/mt
|
||||
- Chrome Conc. S.A. 40%min In port China RMB/dmtu
|
||||
- Chrome Conc. S.A. 42%min CIF China USD/mt
|
||||
- Chrome Conc. S.A. 42%min In port China RMB/dmtu
|
||||
- Chrome Conc. Turkish 46%min CIF China USD/mt
|
||||
- Chrome Conc. Turkish 46%min In port China RMB/dmtu
|
||||
- Chrome Conc. Zimbabwean 48%min CIF China USD/mt
|
||||
- Chrome Conc. Zimbabwean 48%min In port China RMB/dmtu
|
||||
- Chrome Lump 40%min FOB Turkey USD/mt
|
||||
- Chrome Lump S.A. 38%min CIF China USD/mt
|
||||
- Chrome Lump S.A. 38%min In port China RMB/dmtu
|
||||
- Chrome Lump Turkish 40%min CIF China USD/mt
|
||||
- Chrome Lump Turkish 40%min In port China RMB/dmtu
|
||||
|
||||
### Chromium Silicon (1)
|
||||
- Silico-chrome Si 42%min, Cr 30%min EXW China RMB/mt
|
||||
|
||||
### Ferroboron (3)
|
||||
- Ferro-boron B 18%min EXW China RMB/mt
|
||||
- Ferro-boron B 18%min FOB China USD/mt
|
||||
- Ferro-boron B 19%min EXW China RMB/mt
|
||||
|
||||
### Ferrochrome (16)
|
||||
- Ferro-chrome Cr 52%min, C 8%max EXW China RMB/mt 50-basis
|
||||
- Ferro-chrome Cr 55%min, C 0.1%max EXW China RMB/mt 60-basis
|
||||
- Ferro-chrome Cr 55%min, C 0.25%max EXW China RMB/mt 60-basis
|
||||
- Ferro-chrome Cr 60%min, C 0.1%max FOB China USD/lb Cr
|
||||
- Ferro-chrome Cr 60%min, C 0.1%max In warehouse Pittsburgh USD/lb Cr
|
||||
- Ferro-chrome Cr 60%min, C 0.1%max In warehouse Rotterdam USD/lb Cr
|
||||
- Ferro-chrome Cr 60%min, C 8%max In warehouse Pittsburg USD/lb Cr
|
||||
- Ferro-chrome Cr 60%min, C 8%max In warehouse Rotterdam USD/lb Cr
|
||||
- Ferro-chrome Cr 60%min, C 8.5%max In warehouse Russia RUB/kg
|
||||
- Ferro-chrome Cr 65%min, C 0.1%max In warehouse Russia RUB/kg
|
||||
- Ferro-chrome Indian Cr 58%min, C 8%max CIF China USD/lb Cr
|
||||
- Ferro-chrome Indian Cr 58%min, C 8%max In port China RMB/mt 50-basis
|
||||
- Ferro-chrome Kazakhstan Cr 68%min, C 8.5%max CIF China USD/lb Cr
|
||||
- Ferro-chrome Kazakhstan Cr 68%min, C 8.5%max In port China RMB/mt 50-basis
|
||||
- Ferro-chrome S.A. Cr 50%min, C 8%max CIF China USD/lb Cr
|
||||
- Ferro-chrome S.A. Cr 50%min, C 8%max In port China RMB/mt 50-basis
|
||||
|
||||
### Ferromanganese (8)
|
||||
- Ferro-manganese Mn 65%min, C 7%max EXW China RMB/mt
|
||||
- Ferro-manganese Mn 75%min, C 2%max EXW China RMB/mt
|
||||
- Ferro-manganese Mn 75%min, C 8%max EXW China RMB/mt
|
||||
- Ferro-manganese Mn 78%min, C 1.5%max EXW China RMB/mt
|
||||
- Ferro-manganese Mn 78%min, C 7%max In warehouse Russia RUB/kg
|
||||
- Ferro-manganese Mn 78%min, C 8%max Delivered Europe EUR/mt
|
||||
- Ferro-manganese Mn 78%min, C 8%max In warehouse Pittsburgh USD/gt
|
||||
- Ferro-manganese Mn 80%min, C 0.7%max EXW China RMB/mt
|
||||
|
||||
### Ferromolybdenum (8)
|
||||
- Ferro-molybdenum 60%min Delivered India INR/kg
|
||||
- Ferro-molybdenum 60%min EXW China RMB/mt
|
||||
- Ferro-molybdenum 60%min In port South Korea USD/kg Mo
|
||||
- Ferro-molybdenum 60%min In warehouse Russia RUB/kg
|
||||
- Ferro-molybdenum 65%min In warehouse Rotterdam USD/kg Mo
|
||||
- Ferro-molybdenum 65%min In warehouse US USD/kg Mo
|
||||
- Molybdenum Oxide 57%min In port South Korea USD/lb
|
||||
- Molybdenum Oxide 57%min In warehouse Rotterdam USD/lb
|
||||
|
||||
### Ferronickel (4)
|
||||
- Ferro-nickel 1.5%min EXW China RMB/mt
|
||||
- Ferro-nickel 10%min EXW China RMB/mtu
|
||||
- Nickel Ore Philippine 1.5%min CIF China USD/mt
|
||||
- Nickel Ore Philippine 1.8%min In port China RMB/mt
|
||||
|
||||
### Ferroniobium (6)
|
||||
- Ferro-niobium 50-A EXW China RMB/mt
|
||||
- Ferro-niobium 60-A EXW China RMB/mt
|
||||
- Ferro-niobium Brazilian 66% Delivered China RMB/mt
|
||||
- Ferro-niobium Brazilian 66% In warehouse Rotterdam USD/kg Nb
|
||||
- Niobium Conc. Nb2O5 50%min, Ta2O5 5%min, CIF China USD/lb Oxides
|
||||
- Niobium Pentoxide 99.5%min EXW China RMB/kg
|
||||
|
||||
### Ferrophosphorus (1)
|
||||
- Ferro-phosphorus 23%min FOB China USD/mt
|
||||
|
||||
### Ferrosilicon (11)
|
||||
- Ferro-silicon 65%min FOB Russia USD/mt
|
||||
- Ferro-silicon 65%min In warehouse Russia RUB/kg
|
||||
- Ferro-silicon 70%min EXW India INR/mt
|
||||
- Ferro-silicon 72%min EXW China RMB/mt
|
||||
- Ferro-silicon 72%min Ex-VAT FOB China USD/mt
|
||||
- Ferro-silicon 72%min FOB China USD/mt
|
||||
- Ferro-silicon 75%min EXW China RMB/mt
|
||||
- Ferro-silicon 75%min Ex-VAT FOB China USD/mt
|
||||
- Ferro-silicon 75%min FOB China USD/mt
|
||||
- Ferro-silicon 75%min In warehouse Pittsburgh USD/lb Si
|
||||
- Ferro-silicon 75%min In warehouse Rotterdam EUR/mt
|
||||
|
||||
### Ferrotitanium (8)
|
||||
- Ferro-titanium 30% EXW China RMB/mt 25-basis
|
||||
- Ferro-titanium 30% EXW India INR/kg
|
||||
- Ferro-titanium 70% (scrap) EXW China RMB/mt
|
||||
- Ferro-titanium 70% In port India INR/kg
|
||||
- Ferro-titanium 70%min Delivered US USD/kg Ti
|
||||
- Ferro-titanium 70%min EXW Russia RUB/kg
|
||||
- Ferro-titanium 70%min FOB Russia USD/kg Ti
|
||||
- Ferro-titanium 70%min In warehouse Rotterdam USD/kg Ti
|
||||
|
||||
### Ferrotungsten (5)
|
||||
- Ferro-tungsten 70%min EXW China RMB/mt
|
||||
- Ferro-tungsten 75%min EXW China RMB/mt
|
||||
- Ferro-tungsten 75%min FOB China USD/kg W
|
||||
- Ferro-tungsten 75%min FOB Russia USD/kg W
|
||||
- Ferro-tungsten 75%min In warehouse Rotterdam USD/kg W
|
||||
|
||||
### Ferrovanadium (9)
|
||||
- Ferro-vanadium 50%min CIF China USD/kg V
|
||||
- Ferro-vanadium 50%min Delivered China RMB/mt
|
||||
- Ferro-vanadium 50%min In port China RMB/mt
|
||||
- Ferro-vanadium 50%min In warehouse Russia RUB/kg
|
||||
- Ferro-vanadium 80%min FOB China USD/kg V
|
||||
- Ferro-vanadium 80%min In warehouse Pittsburgh USD/kg V
|
||||
- Ferro-vanadium 80%min In warehouse Rotterdam USD/kg V
|
||||
- Vanadium Nitride N 14%min EXW China RMB/mt
|
||||
- Vanadium Pentoxide Flake 98%min In warehouse Rotterdam USD/lb V2O5
|
||||
|
||||
### Manganese Ore (12)
|
||||
- Manganese Ore 38%min FOB S.A. USD/dmtu
|
||||
- Manganese Ore Australian 45%min In Qinzhou Port RMB/mtu
|
||||
- Manganese Ore Australian 45%min In Tianjin Port RMB/mtu
|
||||
- Manganese Ore Brazilian 44%min In Qinzhou Port RMB/mtu
|
||||
- Manganese Ore Brazilian 44%min In Tianjin Port RMB/mtu
|
||||
- Manganese Ore Carbonate, Mn 13%min EXW China RMB/mtu
|
||||
- Manganese Ore Gabonese 44%min In Qinzhou Port RMB/mtu
|
||||
- Manganese Ore Gabonese 44%min In Tianjin Port RMB/mtu
|
||||
- Manganese Ore S.A. 36%min In Qinzhou Port RMB/mtu
|
||||
- Manganese Ore S.A. 36%min In Tianjin Port RMB/mtu
|
||||
- Manganese Ore S.A. 37%min In Qinzhou Port RMB/mtu
|
||||
- Manganese Ore S.A. 37%min In Tianjin Port RMB/mtu
|
||||
|
||||
### Silicomanganese (7)
|
||||
- Silico-manganese 60/14 EXW China RMB/mt
|
||||
- Silico-manganese 60/14 FOB India USD/mt
|
||||
- Silico-manganese 65/17 Delivered Europe EUR/mt
|
||||
- Silico-manganese 65/17 EXW China RMB/mt
|
||||
- Silico-manganese 65/17 FOB India USD/mt
|
||||
- Silico-manganese 65/17 In warehouse Pittsburgh USD/lb
|
||||
- Silico-manganese 65/17 In warehouse Russia RUB/kg
|
||||
|
||||
## Minor Metals (238)
|
||||
|
||||
### Antimony (12)
|
||||
- Antimony Conc. 50%min Delivered China RMB/mt Sb
|
||||
- Antimony Ingot 99.65%min EXW China RMB/mt
|
||||
- Antimony Ingot 99.65%min In port India INR/kg
|
||||
- Antimony Ingot 99.65%min In warehouse Baltimore USD/lb
|
||||
- Antimony Ingot 99.65%min In warehouse Rotterdam USD/mt
|
||||
- Antimony Ingot 99.85%min EXW China RMB/mt
|
||||
- Antimony Trioxide 99.5%min Delivered China RMB/mt
|
||||
- Antimony Trioxide 99.5%min FOB China USD/mt
|
||||
- Antimony Trioxide 99.5%min In warehouse Baltimore USD/lb
|
||||
- Antimony Trioxide 99.5%min In warehouse Rotterdam EUR/kg
|
||||
- Antimony Trioxide 99.8%min Delivered China RMB/mt
|
||||
- Ethylene Glycol Antimony 57%min Delivered China RMB/mt
|
||||
|
||||
### Arsenic (4)
|
||||
- Arsenic Metal 99%min EXW China RMB/mt
|
||||
- Arsenic Metal 99%min FOB China USD/mt
|
||||
- Arsenic Trioxide 99%min EXW China RMB/mt
|
||||
- Arsenic Trioxide 99%min FOB China USD/mt
|
||||
|
||||
### Bismuth (6)
|
||||
- Bismuth Ingot 99.99%min Delivered Europe USD/lb
|
||||
- Bismuth Ingot 99.99%min Delivered India INR/kg
|
||||
- Bismuth Ingot 99.99%min Delivered US USD/lb
|
||||
- Bismuth Ingot 99.99%min EXW China RMB/mt
|
||||
- Bismuth Ingot 99.99%min FOB China USD/lb
|
||||
- Bismuth Oxide 99.9%min EXW China RMB/mt
|
||||
|
||||
### Cadmium (4)
|
||||
- Cadmium Ingot 99.99%min Delivered India INR/kg
|
||||
- Cadmium Ingot 99.99%min EXW China RMB/mt
|
||||
- Cadmium Ingot 99.99%min In warehouse Baltimore USD/lb
|
||||
- Cadmium Ingot 99.99%min In warehouse Rotterdam USD/lb
|
||||
|
||||
### Calcium (6)
|
||||
- Calcium Granule Electrolytic 98.5%min EXW China RMB/mt
|
||||
- Calcium Granule Electrolytic 98.5%min FOB China USD/mt
|
||||
- Calcium Granule Reductive 98%min EXW China RMB/mt
|
||||
- Calcium Granule Reductive 98%min FOB China USD/mt
|
||||
- Calcium Ingot Reductive 98%min EXW China RMB/mt
|
||||
- Fluorspar 97%min Delivered China RMB/mt
|
||||
|
||||
### Chromium (4)
|
||||
- Chromic Anhydride 99.8%min EXW China RMB/mt
|
||||
- Chromium Metal 99%min EXW China RMB/mt
|
||||
- Chromium Metal 99%min FOB China USD/mt
|
||||
- Chromium Oxide Green 99%min EXW China RMB/mt
|
||||
|
||||
### Cobalt (17)
|
||||
- Cobalt Carbonate 46%min Delivered China RMB/mt
|
||||
- Cobalt Chloride 24%min Delivered China RMB/mt
|
||||
- Cobalt Conc. 6-8% CIF China USD/lb Co
|
||||
- Cobalt Intermediate 20-30% CIF China USD/lb Co
|
||||
- Cobalt Metal 99.8%min Delivered China RMB/kg
|
||||
- Cobalt Metal 99.8%min In warehouse Baltimore USD/lb
|
||||
- Cobalt Metal 99.8%min In warehouse Rotterdam USD/lb
|
||||
- Cobalt Monoxide 78%min Delivered China RMB/kg
|
||||
- Cobalt Oxide 72%min Delivered China RMB/kg
|
||||
- Cobalt Oxide 72%min In warehouse Rotterdam EUR/kg
|
||||
- Cobalt Powder 99.5%min Delivered China RMB/kg
|
||||
- Cobalt Sulfate 20.5%min Delivered China RMB/mt
|
||||
- Cobalt Tetroxide 73.5%min Delivered China RMB/kg
|
||||
- Lithium Cobaltate Co 60%min Delivered China RMB/kg
|
||||
- NCM 523 Delivered China RMB/mt
|
||||
- NCM 622 Delivered China RMB/mt
|
||||
- Samarium Cobalt 30H EXW China RMB/kg
|
||||
|
||||
### Gallium (3)
|
||||
- Gallium Metal 99.99%min EXW China RMB/kg
|
||||
- Gallium Metal 99.99%min FOB China USD/kg
|
||||
- Gallium Metal 99.99999%min EXW China RMB/kg
|
||||
|
||||
### Germanium (7)
|
||||
- Germanium Dioxide 99.99%min Delivered US USD/kg
|
||||
- Germanium Dioxide 99.99%min EXW China RMB/kg
|
||||
- Germanium Dioxide 99.99%min FOB China USD/kg
|
||||
- Germanium Metal 99.99%min Delivered Europe USD/kg
|
||||
- Germanium Metal 99.99%min Delivered US USD/kg
|
||||
- Germanium Metal 99.99%min EXW China RMB/kg
|
||||
- Germanium Metal 99.99%min FOB China USD/kg
|
||||
|
||||
### Indium (4)
|
||||
- Indium Ingot 99.995%min Delivered Europe USD/kg
|
||||
- Indium Ingot 99.995%min Delivered US USD/kg
|
||||
- Indium Ingot 99.995%min EXW China RMB/kg
|
||||
- Indium Ingot 99.995%min FOB China USD/kg
|
||||
|
||||
### Lithium (18)
|
||||
- LNCMO 523 Delivered China RMB/kg
|
||||
- LNCMO 622 Delivered China RMB/kg
|
||||
- Lithium Carbonate 99%min Delivered China RMB/mt
|
||||
- Lithium Carbonate 99.5%min CIF China USD/kg
|
||||
- Lithium Carbonate 99.5%min Delivered China RMB/mt
|
||||
- Lithium Carbonate 99.5%min Delivered EU USD/kg
|
||||
- Lithium Carbonate 99.5%min Delivered US USD/kg
|
||||
- Lithium Carbonate 99.5%min FOB South America USD/kg
|
||||
- Lithium Chloride 99.3%min Delivered China RMB/mt
|
||||
- Lithium Hydroxide Monohydrate LiOH 56.5%min Delivered China RMB/mt
|
||||
- Lithium Hydroxide Monohydrate LiOH 56.5%min, Magnets 0.0001%max Delivered China RMB/mt
|
||||
- Lithium Hydroxide Monohydrate LiOH 56.5%min, Magnets 0.0001%max Delivered South Korea USD/kg
|
||||
- Lithium Hydroxide Monohydrate LiOH 56.5%min, Magnets 0.0001%max FOB China USD/kg
|
||||
- Lithium Iron Phosphate Li 3.9%min Delivered China RMB/mt
|
||||
- Lithium Manganate Mn 58% Delivered China RMB/mt
|
||||
- Lithium Metal 99%min Delivered China RMB/mt
|
||||
- Lithium Metal 99.9%min Delivered China RMB/mt
|
||||
- Spodumene Li2O 6%min CIF China USD/mt
|
||||
|
||||
### Magnesium (15)
|
||||
- Magnesium Alloy AM50A EXW China RMB/mt
|
||||
- Magnesium Alloy AM50A FOB China USD/mt
|
||||
- Magnesium Alloy AM60B EXW China RMB/mt
|
||||
- Magnesium Alloy AM60B FOB China USD/mt
|
||||
- Magnesium Alloy AZ91D EXW China RMB/mt
|
||||
- Magnesium Alloy AZ91D FOB China USD/mt
|
||||
- Magnesium Ingot 99.9%min EXW China RMB/mt
|
||||
- Magnesium Ingot 99.9%min Ex-VAT FOB China USD/mt
|
||||
- Magnesium Ingot 99.9%min FOB China USD/mt
|
||||
- Magnesium Ingot 99.9%min In port India INR/kg
|
||||
- Magnesium Ingot 99.9%min In warehouse Rotterdam USD/mt
|
||||
- Magnesium Ingot 99.9%min in warehouse Russia RUB/kg
|
||||
- Magnesium Ingot 99.95%min EXW China RMB/mt
|
||||
- Magnesium Powder 99.9%min EXW China RMB/mt
|
||||
- Magnesium Powder 99.9%min FOB China USD/mt
|
||||
|
||||
### Manganese (11)
|
||||
- Manganese Briquette 97%min FOB China USD/mt
|
||||
- Manganese Dioxide Alkaline 91%min EXW China RMB/mt
|
||||
- Manganese Dioxide Carbon 91%min EXW China RMB/mt
|
||||
- Manganese Flake 99.7%min CIF India USD/mt
|
||||
- Manganese Flake 99.7%min EXW China RMB/mt
|
||||
- Manganese Flake 99.7%min Ex-VAT FOB China USD/mt
|
||||
- Manganese Flake 99.7%min FOB China USD/mt
|
||||
- Manganese Flake 99.7%min In warehouse Rotterdam USD/mt
|
||||
- Manganese Lump 96.5%min FOB China USD/mt
|
||||
- Manganese Lump 96.5%min In warehouse Russia RUB/kg
|
||||
- Manganese Sulfate Mn 32%min EXW China RMB/t
|
||||
|
||||
### Mercury (2)
|
||||
- Mercury Metal 99.9%min Ex-VAT EXW China RMB/kg
|
||||
- Mercury Metal 99.999%min Ex-VAT EXW China RMB/kg
|
||||
|
||||
### Molybdenum (14)
|
||||
- Ammonium Heptamolybdate 54%min EXW China RMB/mt
|
||||
- Ammonium Tetrathiomolybdate 56%min EXW China RMB/mt
|
||||
- Molybdenum Bar 99.9%min EXW China RMB/kg
|
||||
- Molybdenum Conc. 45%min EXW China RMB/mtu
|
||||
- Molybdenum Oxide 57%min In port India USD/lb
|
||||
- Molybdenum Oxide 50%min EXW China RMB/mtu
|
||||
- Molybdenum Oxide 57%min CIF China USD/lb Mo
|
||||
- Molybdenum Oxide 57%min In port South Korea USD/lb
|
||||
- Molybdenum Oxide 57%min In warehouse Rotterdam USD/lb
|
||||
- Molybdenum Oxide 99.9%min EXW China RMB/mt
|
||||
- Molybdenum Powder 99.95%min EXW China RMB/kg
|
||||
- Molybdenum Scrap Cutting Scrap 99.95%min Ex-VAT EXW China RMB/kg
|
||||
- Molybdenum Scrap Wire Scrap 99.95%min Ex-VAT EXW China RMB/kg
|
||||
- Sodium Molybdate 99%min EXW China RMB/mt
|
||||
|
||||
### Niobium (5)
|
||||
- Niobium Conc. Nb2O5 50%min, Ta2O5 5%min, CIF China USD/lb Oxides
|
||||
- Niobium Pentoxide 99.5%min EXW China RMB/kg
|
||||
- Niobium Pentoxide 99.5%min FOB China USD/kg
|
||||
- Niobium Pentoxide 99.99%min EXW China RMB/kg
|
||||
- Niobium Pentoxide 99.99%min FOB China USD/kg
|
||||
|
||||
### Rhenium (1)
|
||||
- Rhenium APR 99.99%min EXW China RMB/kg
|
||||
|
||||
### Selenium (8)
|
||||
- Selenium Dioxide 98%min Delivered China RMB/kg
|
||||
- Selenium Powder 99.5%min In warehouse Rotterdam USD/lb
|
||||
- Selenium Powder 99.9%min CIF China USD/lb
|
||||
- Selenium Powder 99.9%min Delivered China RMB/kg
|
||||
- Selenium Powder 99.9%min Delivered India INR/kg
|
||||
- Selenium Powder 99.9%min Delivered US USD/lb
|
||||
- Selenium Powder 99.9%min FOB Russia USD/kg
|
||||
- Selenium Powder 99.95%min Delivered China RMB/kg
|
||||
|
||||
### Silicon (23)
|
||||
- Polysilicon 9N Delivered China RMB/kg
|
||||
- Silicon Metal 2-2-02 Delivered China RMB/mt
|
||||
- Silicon Metal 2-2-02 Ex-VAT FOB China USD/mt
|
||||
- Silicon Metal 2-2-02 FOB China USD/mt
|
||||
- Silicon Metal 3-3-03 Delivered China RMB/mt
|
||||
- Silicon Metal 3-3-03 Ex-VAT FOB China USD/mt
|
||||
- Silicon Metal 3-3-03 FOB China USD/mt
|
||||
- Silicon Metal 3-3-03 P 50ppm max Delivered China RMB/mt
|
||||
- Silicon Metal 3-3-03 P 50ppm max Ex-VAT FOB China USD/mt
|
||||
- Silicon Metal 3-3-03 P 50ppm max FOB China USD/mt
|
||||
- Silicon Metal 4-2-1 Delivered China RMB/mt
|
||||
- Silicon Metal 4-4-1 Delivered China RMB/mt
|
||||
- Silicon Metal 4-4-1 Delivered Europe EUR/mt
|
||||
- Silicon Metal 4-4-1 Ex-VAT FOB China USD/mt
|
||||
- Silicon Metal 4-4-1 FOB China USD/mt
|
||||
- Silicon Metal 4-4-1 In port India INR/kg
|
||||
- Silicon Metal 5-5-3 Delivered China RMB/mt
|
||||
- Silicon Metal 5-5-3 Delivered Europe EUR/mt
|
||||
- Silicon Metal 5-5-3 Ex-VAT FOB China USD/mt
|
||||
- Silicon Metal 5-5-3 FOB China USD/mt
|
||||
- Silicon Metal 5-5-3 In port India INR/kg
|
||||
- Silicon Metal 98.5%min Delivered US USD/lb
|
||||
- Silicon Powder 99%min Delivered China RMB/mt
|
||||
|
||||
### Strontium (2)
|
||||
- Strontium Metal 99%min EXW China RMB/mt
|
||||
- Strontium Metal 99%min FOB China USD/mt
|
||||
|
||||
### Tantalum (9)
|
||||
- Tantalum Conc. Ta2O5 30%min CIF China USD/lb Ta2O5
|
||||
- Tantalum Conc. Ta2O5 30%min in warehouse Rotterdam USD/lb Ta2O5
|
||||
- Tantalum Metal 99.95%min Delivered US USD/kg
|
||||
- Tantalum Metal 99.95%min EXW China RMB/kg
|
||||
- Tantalum Metal 99.95%min FOB China USD/kg
|
||||
- Tantalum Pentoxide 99.5%min EXW China RMB/kg
|
||||
- Tantalum Pentoxide 99.5%min FOB China USD/kg
|
||||
- Tantalum Pentoxide 99.99%min EXW China RMB/kg
|
||||
- Tantalum Pentoxide 99.99%min FOB China USD/kg
|
||||
|
||||
### Tellurium (3)
|
||||
- Tellurium Metal 99.9%min In warehouse Rotterdam USD/kg
|
||||
- Tellurium Metal 99.99%min EXW China RMB/kg
|
||||
- Tellurium Metal 99.99%min In warehouse Rotterdam USD/kg
|
||||
|
||||
### Titanium (18)
|
||||
- Ilmenite Conc Australian TiO2 55-58%min, Fe 27%min CIF China USD/mt
|
||||
- Ilmenite Conc Kenyan TiO2 48%min, Fe2O3 20%max CIF China USD/mt
|
||||
- Ilmenite Conc Mozambican TiO2 52%min, Fe2O3 25%max CIF China USD/mt
|
||||
- Ilmenite Conc TiO2 46%min; Fe2O3 8%max; Ex-VAT EXW China RMB/mt
|
||||
- Ilmenite Conc TiO2 50%min; Fe 30%min; In port China RMB/mt
|
||||
- Ilmenite Conc TiO2 50%min; Fe2O3 14%max; In port China RMB/mt
|
||||
- Ilmenite Conc TiO2 52%min, Fe2O3 25%max FOB Vietnam USD/mt
|
||||
- Reduced Ilmenite TiO2 56%min EXW China RMB/mt
|
||||
- Rutile Conc TiO2 95%min In port China RMB/mt
|
||||
- Titanium Dioxide A TiO2 98%min Delivered China RMB/mt
|
||||
- Titanium Dioxide R TiO2 93%min Delivered China RMB/mt
|
||||
- Titanium Dioxide R TiO2 93%min FOB China USD/mt
|
||||
- Titanium Pipe TA2 25.4x1mm EXW China RMB/kg
|
||||
- Titanium Plate TA2 2mm EXW China RMB/kg
|
||||
- Titanium Slag TiO2 74%min EXW China RMB/mt
|
||||
- Titanium Slag TiO2 90%min EXW China RMB/mt
|
||||
- Titanium Sponge 99.7%min EXW China RMB/mt
|
||||
- Titanium Tetrachloride 99.99%min EXW China RMB/mt
|
||||
|
||||
### Tungsten (18)
|
||||
- Sodium Tungstate WO3 68%min EXW China RMB/mt
|
||||
- Thorium Tungsten Electrode ThO2 1.7-2.2% EXW China RMB/kg
|
||||
- Tungsten APT 88.5%min EXW China RMB/mt
|
||||
- Tungsten APT 88.5%min FOB China USD/mtu
|
||||
- Tungsten APT 88.5%min In warehouse Rotterdam USD/mtu
|
||||
- Tungsten Bar W-4 99.9%min EXW China RMB/kg
|
||||
- Tungsten Bar W-4 99.9%min FOB China USD/kg
|
||||
- Tungsten Carbide 99.8%min 2.5-7.0?m EXW China RMB/kg
|
||||
- Tungsten Carbide 99.8%min 2.5-7.0?m FOB China USD/kg
|
||||
- Tungsten Ore WO3 50%min FOB Africa USD/mtu
|
||||
- Tungsten Ore WO3 65%min EXW China RMB/mt
|
||||
- Tungsten Oxide WO3 99.95%min EXW China RMB/mt
|
||||
- Tungsten Oxide WO3 99.95%min FOB China USD/mtu
|
||||
- Tungsten Powder 99.95%min 2.5-7.0?m EXW China RMB/kg
|
||||
- Tungsten Scrap Anvil 90%min Ex-VAT Delivered China RMB/kg
|
||||
- Tungsten Scrap CNC Cutting Blades 70%min Ex-VAT Delivered China RMB/kg
|
||||
- Tungsten Scrap Drills 70%min Ex-VAT Delivered China RMB/kg
|
||||
- Tungsten Scrap Grinding Swarf W 70%min, Co 5%min Ex-VAT Delivered China RMB/mtu
|
||||
|
||||
### Vanadium (11)
|
||||
- Ammonium Metavanadate 98%min EXW China RMB/mt
|
||||
- Vanadium Nitride N 14%min EXW China RMB/mt
|
||||
- Vanadium Pentoxide Flake 92%min CIF China USD/lb V2O5
|
||||
- Vanadium Pentoxide Flake 92%min In port China RMB/mt
|
||||
- Vanadium Pentoxide Flake 98%min CIF China USD/lb V2O5
|
||||
- Vanadium Pentoxide Flake 98%min EXW China RMB/mt
|
||||
- Vanadium Pentoxide Flake 98%min In port China RMB/mt
|
||||
- Vanadium Pentoxide Flake 98%min In warehouse Rotterdam USD/lb V2O5
|
||||
- Vanadium Pentoxide Powder 98%min EXW China RMB/mt
|
||||
- Vanadium Pentoxide Powder 99%min EXW China RMB/mt
|
||||
- Vanadium Pentoxide Powder 99.5%min EXW China RMB/mt
|
||||
|
||||
### Zirconium (13)
|
||||
- Fused Zirconia Zr(Hf)O2 98.5%min EXW China RMB/mt
|
||||
- Zircon Sand Australian Zr(Hf)O2 66%min In port China RMB/mt
|
||||
- Zircon Sand Australian Zr(Hf)O2 66%min In port China USD/mt
|
||||
- Zircon Sand Indonesian Zr(Hf)O2 66%min CIF China USD/mt
|
||||
- Zircon Sand S.A. Zr(Hf)O2 65%min In port China RMB/mt
|
||||
- Zircon Sand S.A. Zr(Hf)O2 65%min In port China USD/mt
|
||||
- Zircon Sand S.A. Zr(Hf)O2 66%min In port China USD/mt
|
||||
- Zircon Sand Zr(Hf)O2 65%min EXW China RMB/mt
|
||||
- Zirconium Carbonate Zr(Hf)O2 40%min EXW China RMB/mt
|
||||
- Zirconium Carbonate Zr(Hf)O2 40%min, Cl 50ppm EXW China RMB/mt
|
||||
- Zirconium Oxychloride Zr(Hf)O2 36%min EXW China RMB/mt
|
||||
- Zirconium Silicate Zr(Hf)O2 64.5%min EXW China RMB/mt
|
||||
- Zirconium Sponge Zr+Hf 99.4%min EXW China RMB/kg
|
||||
|
||||
## Rare Earths (54)
|
||||
|
||||
### Lutetium (1)
|
||||
- Lutetium Oxide 99.99%min China RMB/kg
|
||||
|
||||
### Neodymium (27)
|
||||
- Cerium Carbonate TREO 45%min, CeO2/REO 100% EXW China RMB/mt
|
||||
- Cerium Carbonate TREO 45%min, CeO2/REO 100% FOB China USD/mt
|
||||
- Cerium Metal 99%min EXW China RMB/mt
|
||||
- Cerium Metal 99%min FOB China USD/kg
|
||||
- Cerium Oxide 99%min FOB China USD/mt
|
||||
- Cerium Oxide 99.9%min EXW China RMB/mt
|
||||
- Cerium Oxide 99.9%min In warehouse Rotterdam USD/kg
|
||||
- Cerium Oxide 99.99%min EXW China RMB/mt
|
||||
- LaCe Carbonate TREO 45%min, CeO2/REO 65±2% EXW China RMB/mt
|
||||
- LaCe Chloride TREO 45%min, CeO2/REO 65±2% EXW China RMB/mt
|
||||
- NdFeB Sintered Rough 35H EXW China RMB/kg
|
||||
- NdFeB Sintered Rough 35M EXW China RMB/kg
|
||||
- NdFeB Sintered Rough 45H EXW China RMB/kg
|
||||
- NdFeB Sintered Rough 45M EXW China RMB/kg
|
||||
- NdFeB Sintered Rough 48H EXW China RMB/kg
|
||||
- NdFeB Sintered Rough 50H EXW China RMB/kg
|
||||
- NdFeB Sintered Rough 50M EXW China RMB/kg
|
||||
- NdFeB Sintered Rough N35 EXW China RMB/kg
|
||||
- NdFeB Sintered Rough N45 EXW China RMB/kg
|
||||
- NdFeB Sintered Rough N52 EXW China RMB/kg
|
||||
- Neodymium Metal 99%min EXW China RMB/mt
|
||||
- Neodymium Metal 99%min FOB China USD/kg
|
||||
- Neodymium Oxide 99.5%min EXW China RMB/mt
|
||||
- Neodymium Oxide 99.5%min FOB China USD/mt
|
||||
- PrNd Mischmetal Pr 25%, Nd 75% EXW China RMB/mt
|
||||
- PrNd Mischmetal Pr 25%, Nd 75% FOB China USD/kg
|
||||
- PrNd Oxide Pr6O11 25%, Nd2O3 75% EXW China RMB/mt
|
||||
|
||||
### Praseodymium (4)
|
||||
- Praseodymium Metal 99.5%min FOB China USD/kg
|
||||
- Praseodymium Oxide 99.5%min EXW China RMB/mt
|
||||
- Praseodymium Oxide 99.5%min FOB China USD/mt
|
||||
- Praseodymium Oxide 99.5%min In warehouse Rotterdam USD/kg
|
||||
|
||||
### Samarium (6)
|
||||
- Samarium Cobalt 30H EXW China RMB/kg
|
||||
- Samarium Metal 99.5%min EXW China RMB/kg
|
||||
- Samarium Metal 99.5%min FOB China USD/kg
|
||||
- Samarium Oxide 99.9%min EXW China RMB/mt
|
||||
- Samarium Oxide 99.9%min FOB China USD/mt
|
||||
- SmEuGd Oxide TREO 220g/L min, Eu2O3/REO 8%min EXW China RMB/mt
|
||||
|
||||
### Scandium (4)
|
||||
- Scandium Aluminum Sc 2% EXW China RMB/kg
|
||||
- Scandium Metal 99.99%min EXW China RMB/kg
|
||||
- Scandium Metal 99.999%min EXW China RMB/kg
|
||||
- Scandium Oxide 99.99%min EXW China RMB/kg
|
||||
|
||||
### Terbium (4)
|
||||
- Terbium Metal 99.9%min EXW China RMB/kg
|
||||
- Terbium Metal 99.9%min FOB China USD/kg
|
||||
- Terbium Oxide 99.99%min EXW China RMB/kg
|
||||
- Terbium Oxide 99.99%min FOB China USD/kg
|
||||
|
||||
### Ytterbium (1)
|
||||
- Ytterbium Oxide 99.99%min EXW China RMB/kg
|
||||
|
||||
### Yttrium (7)
|
||||
- YEu Oxide Eu2O3/REO 6.6%min EXW China RMB/kg
|
||||
- YEu Oxide Eu2O3/REO 6.6%min FOB China USD/kg
|
||||
- Yttrium Metal 99.9%min EXW China RMB/kg
|
||||
- Yttrium Metal 99.9%min FOB China USD/kg
|
||||
- Yttrium Oxide 99.999%min EXW China RMB/mt
|
||||
- Yttrium Oxide 99.999%min FOB China USD/kg
|
||||
- Yttrium Oxide 99.999%min In warehouse Rotterdam USD/kg
|
||||
|
||||
## Refractories (77)
|
||||
|
||||
### Calcined Bauxite (3)
|
||||
- Calcined Bauxite 85%min 1-3mm EXW China RMB/mt
|
||||
- Calcined Bauxite 86%min 1-3mm FOB China USD/mt
|
||||
- Calcined Bauxite 87%min 1-3mm EXW China RMB/mt
|
||||
|
||||
### Carbon (15)
|
||||
- Calcined Pet Coke S 3%max EXW China RMB/mt
|
||||
- Coal Tar Pitch Coking Value 53%min EXW China RMB/mt
|
||||
- Graphite Electrode H.P. D300mm FOB China USD/mt
|
||||
- Graphite Electrode H.P. D400mm EXW China RMB/mt
|
||||
- Graphite Electrode H.P. D450mm FOB China USD/mt
|
||||
- Graphite Electrode H.P. D500mm EXW China RMB/mt
|
||||
- Graphite Electrode R.P. D400mm EXW China RMB/mt
|
||||
- Graphite Electrode U.H.P. D500mm EXW China RMB/mt
|
||||
- Graphite Electrode U.H.P. D500mm FOB China USD/mt
|
||||
- Graphite Electrode U.H.P. D600mm EXW China RMB/mt
|
||||
- Graphite Electrode U.H.P. D600mm FOB China USD/mt
|
||||
- Pet Coke S 2%max EXW China RMB/mt
|
||||
- Pet Coke S 3%max EXW China RMB/mt
|
||||
- Prebaked Anode S 3%max EXW China RMB/mt
|
||||
- Prebaked Anode S 3%max FOB China USD/mt
|
||||
|
||||
### Fused Alumina (16)
|
||||
- Calcined Alumina 99%min 1-3mm US USD/st
|
||||
- Fused Alumina Brown 95%min 1-3mm EXW China RMB/mt
|
||||
- Fused Alumina Brown 95%min 1-3mm EXW India INR/mt
|
||||
- Fused Alumina Brown 95%min 1-3mm FOB China USD/mt
|
||||
- Fused Alumina Brown 95%min 1-3mm In warehouse Rotterdam EUR/mt
|
||||
- Fused Alumina Brown 95%min 1-3mm US USD/st
|
||||
- Fused Alumina Brown 95%min F36 EXW China RMB/mt
|
||||
- Fused Alumina Brown 95%min F36 EXW India INR/mt
|
||||
- Fused Alumina Brown 95%min F36 FOB China USD/mt
|
||||
- Fused Alumina White 99%min 1-3mm EXW China RMB/mt
|
||||
- Fused Alumina White 99%min 1-3mm FOB China USD/mt
|
||||
- Fused Alumina White 99%min 1-3mm In warehouse Rotterdam EUR/mt
|
||||
- Fused Alumina White 99%min 1-3mm US USD/st
|
||||
- Fused Alumina White 99%min F36 EXW China RMB/mt
|
||||
- Fused Alumina White 99%min F36 FOB China USD/mt
|
||||
- Tabular Alumina 99%min 1-3mm US USD/st
|
||||
|
||||
### Graphite (11)
|
||||
- Amorphous Graphite 75%min 0-3mm EXW China RMB/mt
|
||||
- Amorphous Graphite 80%min 20-50mm EXW China RMB/mt
|
||||
- Amorphous Graphite 80%min 200mesh min EXW China RMB/mt
|
||||
- Expansible Graphite 99 80 250 EXW China RMB/mt
|
||||
- Flake Graphite +195 EXW China RMB/mt
|
||||
- Flake Graphite +895 EXW China RMB/mt
|
||||
- Flake Graphite -190 EXW China RMB/mt
|
||||
- Flake Graphite -194 EXW China RMB/mt
|
||||
- Flake Graphite -194 FOB China USD/mt
|
||||
- Flake Graphite -199 EXW China RMB/mt
|
||||
- Spherical Graphite 99.95%min 17?m max EXW China RMB/mt
|
||||
|
||||
### Magnesia (21)
|
||||
- Magnesia C.C. 85%min 200mesh EXW China RMB/mt
|
||||
- Magnesia C.C. 90%min 200mesh EXW China RMB/mt
|
||||
- Magnesia C.C. 90%min 200mesh FOB China USD/mt
|
||||
- Magnesia C.C. 97%min 0-5mm EXW China RMB/mt
|
||||
- Magnesia D.B. 90%min 3-15mm EXW China RMB/mt
|
||||
- Magnesia D.B. 90%min 3-15mm FOB China USD/mt
|
||||
- Magnesia D.B. 90%min 3-15mm In warehouse Rotterdam EUR/mt
|
||||
- Magnesia D.B. 90%min 3-15mm US USD/st
|
||||
- Magnesia D.B. 95%min 0-30mm EXW China RMB/mt
|
||||
- Magnesia D.B. 95%min 0-30mm FOB China USD/mt
|
||||
- Magnesia D.B. 95%min 0-30mm In warehouse Rotterdam EUR/mt
|
||||
- Magnesia D.B. 96.5%min 0-30mm EXW China RMB/mt
|
||||
- Magnesia D.B. 97%min 0-30mm EXW China RMB/mt
|
||||
- Magnesia D.B. 97%min 0-30mm FOB China USD/mt
|
||||
- Magnesia E.F. 97%min EXW China RMB/mt
|
||||
- Magnesia E.F. 97%min H-Ca EXW China RMB/mt
|
||||
- Magnesia E.F. 97%min H-Ca FOB China USD/mt
|
||||
- Magnesia E.F. 97.5%min Large Crystal EXW China RMB/mt
|
||||
- Magnesia E.F. 97.5%min Large Crystal FOB China USD/mt
|
||||
- Magnesite Lump 47%min EXW China RMB/mt
|
||||
- Magnesite Pellet 47%min EXW China RMB/mt
|
||||
|
||||
### Silicon Carbide (11)
|
||||
- Silicon Carbide Black 88%min 0-10mm EXW China RMB/mt
|
||||
- Silicon Carbide Black 88%min 0-10mm FOB China USD/mt
|
||||
- Silicon Carbide Black 98%min 10-30cm EXW China RMB/mt
|
||||
- Silicon Carbide Black 98%min F16-100 EXW China RMB/mt
|
||||
- Silicon Carbide Black 98%min F16-100 FOB China USD/mt
|
||||
- Silicon Carbide Green 98%min F240 EXW China RMB/mt
|
||||
- Silicon Carbide Green 98%min F240 FOB China USD/mt
|
||||
- Silicon Carbide Green 98%min JIS2000 EXW China RMB/mt
|
||||
- Silicon Carbide Green 98%min JIS2000 FOB China USD/mt
|
||||
- Silicon Carbide Green 98.5%min 10-30cm EXW China RMB/mt
|
||||
- Silicon Carbide Black 88%min 0-10mm In warehouse Rotterdam EUR/mt
|
||||
|
||||
## Stainless & Special (16)
|
||||
|
||||
### Bearing Steel (10)
|
||||
- Cold Heading Wire 22A ?6.5mm In warehouse Shanghai RMB/mt
|
||||
- Electrical Steel Grain Oriented 130 0.3*980mm In warehouse China RMB/mt
|
||||
- Electrical Steel Non-grain 470 0.5*1200mm In warehouse China RMB/mt
|
||||
- Electrical Steel Non-grain 800 0.5*1,200mm In warehouse China RMB/mt
|
||||
- Stainless Bar 321 60mm In warehouse Dainan RMB/mt
|
||||
- Stainless CR Coil 201/2B 1.0mm In warehouse China RMB/mt
|
||||
- Stainless CR Coil 304/2B 1.0mm In warehouse China RMB/mt
|
||||
- Stainless CR Coil 430/2B 1.0mm In warehouse China RMB/mt
|
||||
- Stainless HR Coil 201/No.1 6.0mm In warehouse China RMB/mt
|
||||
- Stainless HR Coil 304/No.1 6.0mm In warehouse China RMB/mt
|
||||
|
||||
### Stainless Bar (2)
|
||||
- Stainless Bar 304 60mm In warehouse Dainan RMB/mt
|
||||
- Stainless Bar 321 60mm In warehouse Dainan RMB/mt
|
||||
|
||||
### Stainless Pipe (1)
|
||||
- Stainless Seamless Pipe 304 108*4mm In warehouse Wenzhou RMB/mt
|
||||
|
||||
### Stainless Scrap (2)
|
||||
- Stainless Scrap 304 Solid In warehouse Tokyo JPY/mt
|
||||
- Stainless Scrap 304 Solid In warehouse Dainan RMB/mt
|
||||
|
||||
### Structural Steel (1)
|
||||
- Stainless CR Coil 304/2B 1.0mm In warehouse China RMB/mt
|
||||
|
||||
## Steel Raw Materials (28)
|
||||
|
||||
### Coal (5)
|
||||
- Coking Coal A 10%max, S 0.8%max EXW Hebei RMB/mt
|
||||
- Coking Coal A 10%max, S 0.8%max EXW Henan RMB/mt
|
||||
- Coking Coal A 10.5%max, S 0.8%max EXW Shanxi RMB/mt
|
||||
- Thermal Coal Q5000 In port Qinhuangdao RMB/mt
|
||||
- Thermal Coal Q5500 In port Qinhuangdao RMB/mt
|
||||
|
||||
### Coke (3)
|
||||
- Met. Coke A 13%max, S 0.7%max EXW Hebei RMB/mt
|
||||
- Met. Coke A 13%max, S 0.7%max EXW Shandong RMB/mt
|
||||
- Met. Coke A 13%max, S 0.7%max EXW Shanxi RMB/mt
|
||||
|
||||
### Iron (3)
|
||||
- Cast Iron Z18-22 EXW China RMB/mt
|
||||
- Pig Iron L8-10 EXW China RMB/mt
|
||||
- S.G. Cast Iron Q10-12 EXW China RMB/mt
|
||||
|
||||
### Iron Ore (9)
|
||||
- Iron Ore Fine 65%min EXW Benxi RMB/dt
|
||||
- Iron Ore Fine 65%min EXW Laiwu RMB/dt
|
||||
- Iron Ore Fine 65%min Ex-VAT EXW Daixian RMB/wt
|
||||
- Iron Ore Fine 66%min Ex-VAT EXW Handan RMB/dt
|
||||
- Iron Ore Fine Australian 58%min In port China RMB/wt
|
||||
- Iron Ore Fine Australian 62%min CNF China USD/dt
|
||||
- Iron Ore Fine Australian 62%min In port China RMB/wt
|
||||
- Iron Ore Fine Brazilian 65%min In port China RMB/wt
|
||||
- Iron Ore Lump Australian 62%min In port China RMB/wt
|
||||
|
||||
### Steel Billet (2)
|
||||
- Steel Billet HRB335 150x150mm EXW China RMB/mt
|
||||
- Steel Billet Q235 150x150mm EXW China RMB/mt
|
||||
|
||||
### Steel Scrap (6)
|
||||
- Steel Scrap HMS 4-6mm EXW Shandong RMB/mt
|
||||
- Steel Scrap HMS 8mm min EXW Wuxi RMB/mt
|
||||
- Steel Scrap MMS 4-6mm EXW Guangzhou RMB/mt
|
||||
- Steel Scrap MMS 4-6mm EXW Tianjin RMB/mt
|
||||
- Steel Scrap MMS 6-10mm EXW Liaoning RMB/mt
|
||||
- Steel Scrap Process Scrap Delivered China RMB/mt
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Didvan is used by market analysts, steel/metals operators, administrators, and trusted partner systems that need current market data. Admin users manage access, data-source tokens, health checks, reports, and operational visibility.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
The product collects, stores, and serves market data for metals, steel, domestic Iranian markets, commodities, crypto, steel-company stocks, macro indicators, and reports. Success means the dashboard and partner API expose current data reliably, with clear access control and enough operational visibility to know when feeds are healthy.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Calm, exact, operational. The interface should feel like a serious market-intelligence tool: dense enough for work, readable under repeated use, and restrained rather than promotional.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Avoid marketing-page composition, decorative cards, oversized hero treatments, vague SaaS copy, and visual effects that distract from data quality, security, or admin workflows.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- Make operational state visible: users should quickly see what is fresh, broken, authorized, or stale.
|
||||
- Prefer explicit data tables and compact controls over decorative layouts.
|
||||
- Treat security as a visible workflow: tokens, access, and usage should be inspectable without exposing secrets.
|
||||
- Keep Persian/RTL admin workflows readable while preserving exact technical values such as paths, tokens, and timestamps.
|
||||
- Optimize for repeated work: low-friction scanning, filtering, and verification matter more than spectacle.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Use clear contrast, readable table density, keyboard-accessible controls, and reduced-motion-safe UI. Preserve UTF-8 Persian text correctly and keep technical identifiers legible in monospace where helpful.
|
||||
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
<div dir="rtl">
|
||||
|
||||
# سامانهٔ جامع آمار و اطلاعات «دیدوان»
|
||||
|
||||
داشبورد زندهٔ بازار فلزات، فولاد، ارز، طلا/سکه، کامودیتی و کریپتو — بههمراه آرشیو گزارشهای منابع معتبر جهانی. این سند برای **توسعهدهندهٔ بعدی** نوشته شده؛ کل معماری، نحوهٔ کار، راهاندازی، امنیت و دامها (gotchas) را پوشش میدهد. کامل بخوان.
|
||||
|
||||
---
|
||||
|
||||
## ۱) این سیستم چیست؟
|
||||
|
||||
یک اپلیکیشن سهبخشی:
|
||||
|
||||
| بخش | تکنولوژی | پورت | نقش |
|
||||
|---|---|---|---|
|
||||
| **فرانتاند** | TanStack Start + React 19 + Vite 7 + Tailwind v4 | `5174` | لندینگ، ورود/ثبتنام، داشبورد |
|
||||
| **بکاند (API)** | FastAPI + SQLite + Playwright | `8000` | اسکرپرها، API دادهٔ بازار، احراز هویت |
|
||||
| **پنل ادمین** | FastAPI (HTML درونخطی) | `8001` | بازبینی/تأیید کاربران، مدیریت توکن، سلامت سرویسها، گزارشها |
|
||||
|
||||
دیتای بازار با **اسکرپرهای پسزمینه** جمع میشود و در **SQLite** ذخیره میشود (فقط مقادیرِ تغییرکرده — dedup). فرانتاند هر چند ثانیه از API میخواند و «زنده» نشان میدهد.
|
||||
|
||||
---
|
||||
|
||||
## ۲) ساختار پوشهها
|
||||
|
||||
```
|
||||
asianmetal-dashboard/
|
||||
├─ src/ # فرانتاند
|
||||
│ ├─ routes/ # مسیرهای فایلمحورِ TanStack
|
||||
│ │ ├─ index.tsx # لندینگ (LaserFlow، پنل زنده، گالریها، …)
|
||||
│ │ ├─ login.tsx # ورود (کاراکترهای کارتونی + احراز واقعی)
|
||||
│ │ ├─ register.tsx # ثبتنام (→ pending)
|
||||
│ │ ├─ dashboard.tsx # داشبورد + همهٔ تبها/کارتها/چارتها
|
||||
│ │ └─ __root.tsx # لایهٔ ریشه (RTL، فونت Vazirmatn، لود styles)
|
||||
│ ├─ components/ # LaserFlow, CircularGallery, CardSwap, TubelightNavbar, …
|
||||
│ ├─ components/ui/ # shadcn/ui
|
||||
│ ├─ lib/ # labels.ts، metals-data.ts، utils.ts
|
||||
│ ├─ styles.css # توکنهای تم (oklch)، .tile/.glass/.panel، انیمیشنها
|
||||
│ └─ routeTree.gen.ts # خودکار توسط TanStack (دستی ویرایش نکن)
|
||||
│
|
||||
├─ backend/ # API + اسکرپرها
|
||||
│ ├─ main.py # اپ FastAPI، CORS/هدرها/ریتلیمیت، همهٔ اندپوینتها، lifespan
|
||||
│ ├─ db.py # اتصال SQLite + init_db (ساخت/مهاجرت جداول)
|
||||
│ ├─ auth.py # هشِ رمز (PBKDF2) + نشستهای سمتسرور
|
||||
│ ├─ currency_stream.py # اسکرپِ ارز/طلا/سکهٔ داخلی (Playwright، هر ۶۰ث)
|
||||
│ ├─ crypto_market.py # کریپتو از API رایگانِ CoinGecko (هر ۶۰ث)
|
||||
│ ├─ world_economy.py # شاخصهای کلانِ اقتصادهای بزرگ (هفتگی)
|
||||
│ ├─ steel_stocks.py # سهام شرکتهای فولادی (companiesmarketcap، هر ۵ دقیقه)
|
||||
│ ├─ live_stream.py # کامودیتی لحظهای (Yahoo Finance) روی WebSocket
|
||||
│ ├─ reports.py # اسکنِ آرشیو PDFِ گزارشها (فایلسیستم)
|
||||
│ └─ sync.py # اسکریپت دستیِ سینکِ قیمت فلزات از API منبعِ بالادست
|
||||
│
|
||||
├─ admin/admin_app.py # پنل ادمین (مستقل)
|
||||
├─ vite.config.ts # پورت ۵۱۷۴ + proxy (/api,/ws → :8000) + allowedHosts
|
||||
├─ .gitignore # شاملِ .env، __pycache__، *.db
|
||||
└─ README.md # همین فایل
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۳) راهاندازی محلی
|
||||
|
||||
**پیشنیازها:** Node 20+ (یا Bun)، Python 3.11+، و برای اسکرپرها `playwright install chromium`.
|
||||
|
||||
```powershell
|
||||
# نصب وابستگیهای فرانت
|
||||
npm install
|
||||
|
||||
# نصب وابستگیهای پایتون (نمونه)
|
||||
pip install fastapi uvicorn playwright requests yfinance pandas websockets
|
||||
python -m playwright install chromium
|
||||
```
|
||||
|
||||
### اجرای هر سه سرویس (هرکدام در ترمینال جدا)
|
||||
|
||||
```powershell
|
||||
# ۱) بکاند (پورت 8000)
|
||||
$env:PYTHONUTF8="1"; python -m uvicorn main:app --host 127.0.0.1 --port 8000 --app-dir backend
|
||||
|
||||
# ۲) فرانتاند (پورت 5174)
|
||||
npm run dev
|
||||
|
||||
# ۳) پنل ادمین (پورت 8001) — ADMIN_PASSWORD الزامی است
|
||||
$env:ADMIN_USER="admin"; $env:ADMIN_PASSWORD="یکرمزِقوی"; python -m uvicorn admin_app:app --host 127.0.0.1 --port 8001 --app-dir admin
|
||||
```
|
||||
|
||||
سپس: فرانت روی `http://localhost:5174` ، پنل ادمین روی `http://localhost:8001`.
|
||||
|
||||
---
|
||||
|
||||
## ۴) متغیرهای محیطی
|
||||
|
||||
| متغیر | کجا | پیشفرض | توضیح |
|
||||
|---|---|---|---|
|
||||
| `DB_PATH` | backend, admin | مسیر محلی | محل فایل SQLite (هر دو سرویس باید یکی باشند) |
|
||||
| `ASIANMETAL_TOKEN` | sync.py | — | توکنِ API بالادست (یا از پنل ادمین در DB) — **هیچوقت در کد هاردکد نکن** |
|
||||
| `ADMIN_USER` / `ADMIN_PASSWORD` | admin | `admin` / **الزامی** | احراز Basic پنل ادمین؛ بدون `ADMIN_PASSWORD` بالا نمیآید (fail-closed) |
|
||||
| `ALLOWED_ORIGINS` | backend | `localhost:5174,127.0.0.1:5174` | originهای مجاز CORS (با کاما) |
|
||||
| `COOKIE_SECURE` | backend | `0` | در پروداکشن (HTTPS) روی `1` بگذار تا کوکیِ نشست Secure شود |
|
||||
| `REPORTS_DIR` | reports.py | پوشهٔ آرشیو | محل PDFهای گزارش (ساختار `<منبع> - <دسته>/<file>.pdf`) |
|
||||
|
||||
---
|
||||
|
||||
## ۵) دیتابیس (SQLite)
|
||||
|
||||
`init_db()` در `db.py` جداول را میسازد و **مهاجرتِ امن** انجام میدهد (ستونها را اگر نبودند `ALTER` میکند):
|
||||
|
||||
- **`prices`** — تاریخچهٔ روزانهٔ قیمت فلزات: `grp, category, title, date, low, mid, high` (یکتا روی `title,date`). منبعِ تب «فلزات و فولاد».
|
||||
- **`users`** — کاربران: `id, name, email, phone, organization, password_hash, status('pending'|'approved'|'rejected'), created_at`.
|
||||
- **`sessions`** — نشستهای سمتسرور: `token, user_id, created_at, expires_at`.
|
||||
- **`app_config`** — جفتهای `key/value` (مثلاً `asianmetal_token`، هارتبیت اسکرپرها).
|
||||
- جداول اسکرپرها: `currency_prices, gold_prices, coin_prices`, `te_prices`, `world_economy`, `steel_stocks` — هرکدام آخرین/تاریخچهٔ مقادیر را نگه میدارند (dedup روی تغییر).
|
||||
|
||||
> نکته: قیمتهای لحظهای (ارز/کریپتو/کامودیتی) عمدتاً در **حافظه** کش میشوند (`_latest`) و فقط تغییرها در DB ذخیره میشوند تا حجم کم بماند.
|
||||
|
||||
---
|
||||
|
||||
## ۶) اسکرپرها (در `lifespan` بکاند خودکار استارت میشوند)
|
||||
|
||||
| ماژول | منبع | بازه | جدول/خروجی |
|
||||
|---|---|---|---|
|
||||
| `currency_stream` | نرخ داخلیِ ارز/طلا/سکه (URL در همان فایل) | ۶۰ ثانیه | `currency_prices`/`gold_prices`/`coin_prices` |
|
||||
| `crypto_market` | **CoinGecko** (API رایگان، بدون کلید) | ۶۰ ثانیه | حافظه (`get_latest`) |
|
||||
| `world_economy` | شاخصهای کلانِ اقتصادهای بزرگ | هفتگی | `world_economy` |
|
||||
| `steel_stocks` | companiesmarketcap.com | ۵ دقیقه | `steel_stocks` |
|
||||
| `live_stream` | **Yahoo Finance** | لحظهای | WebSocket `/ws/live` |
|
||||
| `reports` | فایلسیستمِ `REPORTS_DIR` | کشِ ۱۰ دقیقه | اسکنِ PDF |
|
||||
|
||||
**دامِ مهم:** سایتهای منبع قیمتها را با JS لِیزیلود میکنند. اسکرپرهای Playwright بعد از `goto` یک `wait_for_timeout(5000–9000)` دارند؛ **حذفش نکن** وگرنه مقادیرِ کهنه/خالی خوانده میشود. هر اسکرپر هم try/except دارد تا یک خطای گذرا کلِ تسک پسزمینه را نکُشد.
|
||||
|
||||
`sync.py` یک اسکریپتِ **دستی** است (در lifespan نیست) که قیمت فلزات را از API بالادست میگیرد؛ توکن را از `app_config` یا env `ASIANMETAL_TOKEN` میخواند.
|
||||
|
||||
---
|
||||
|
||||
## ۷) اندپوینتهای API (بکاند `:8000`)
|
||||
|
||||
**عمومی (دادهٔ بازار):** `GET /api/tree`, `/api/history?title=&days=`, `/api/search?q=`, `/api/latest`, `/api/live`, `/api/currency`, `/api/gold`, `/api/coin`, `/api/world-economy`, `/api/steel-stocks`, `/api/crypto`, `/api/reports`, `/api/reports/files?folder=`, `/api/reports/file?folder=&name=` (استریمِ inline PDF با گاردِ traversal)، WebSocket `/ws/live`.
|
||||
|
||||
**احراز هویت:** `POST /api/register`، `POST /api/login`، `GET /api/me`، `POST /api/logout`.
|
||||
|
||||
**پنل ادمین `:8001`** (پشت HTTP Basic): `/api/users`، `POST /api/users/{id}/approve|reject`، `GET|POST /api/token`، `/api/health`، `/api/test-scrape`، `/api/reports`، `/api/export?dataset=`.
|
||||
|
||||
---
|
||||
|
||||
## ۸) احراز هویت و گردشِ تأیید (مهم)
|
||||
|
||||
> اصل: **هیچ اعتمادی به کلاینت نیست.** ورود کاملاً سمتسرور تأیید میشود؛ نشست در **کوکیِ httpOnly** است، نه `localStorage`.
|
||||
|
||||
```
|
||||
ثبتنام (نام، نامخانوادگی، سازمان، ایمیل، موبایل، رمز)
|
||||
│ POST /api/register → رمز با PBKDF2 هش میشود، رکورد با status='pending'
|
||||
▼
|
||||
کاربر در وضعیت «در انتظار تأیید» ──► ادمین در پنل (تبِ کاربران) تأیید/رد میکند
|
||||
│ (POST /api/users/{id}/approve | reject)
|
||||
▼
|
||||
ورود POST /api/login → اگر approved بود: کوکیِ نشست (httpOnly, SameSite=Lax) ست میشود
|
||||
│ pending → 403 «در انتظار تأیید» ، rejected → 403 ، رمز غلط → 401
|
||||
▼
|
||||
داشبورد → گاردِ سمتسرور: fetch('/api/me')؛ اگر 401 شد → ریدایرکت به /login
|
||||
خروج POST /api/logout → نشست از DB حذف + کوکی پاک میشود
|
||||
```
|
||||
|
||||
نکات پیادهسازی:
|
||||
- هشِ رمز: `auth.hash_password` با `hashlib.pbkdf2_hmac('sha256', ..., 200000)` — بدونِ وابستگیِ خارجی. مقایسه با `hmac.compare_digest`.
|
||||
- توکنِ نشست: `secrets.token_urlsafe(32)`، انقضای ۷ روزه، در جدول `sessions`.
|
||||
- ریتلیمیتِ per-IP (در حافظه) روی `register`/`login` (۵ در دقیقه).
|
||||
- اعتبارسنجیِ Pydantic: فرمت/طولِ ایمیل و موبایل، حداقل ۸ کاراکترِ رمز.
|
||||
- رد کردنِ کاربر، نشستهای فعالش را هم پاک میکند (دسترسی فوراً قطع).
|
||||
|
||||
---
|
||||
|
||||
## ۹) پنل ادمین (`:8001`)
|
||||
|
||||
- ورود با HTTP Basic؛ `ADMIN_PASSWORD` **الزامی** است (بدونش بالا نمیآید).
|
||||
- تبها: **کاربران** (لیست + تأیید/رد + سازمان/وضعیت)، **توکن منبع داده** (ذخیره/تستِ توکن بالادست)، **سلامت سرویسها** (هارتبیتِ هر اسکرپر + تست اسکرپ)، **گزارشات** (دیتاستها + خروجی CSV).
|
||||
- خروجیِ کاربران در UI با `esc()` فرار داده میشود (جلوگیری از XSSِ ذخیرهشده).
|
||||
|
||||
---
|
||||
|
||||
## ۱۰) فرانتاند
|
||||
|
||||
- مسیرها: `/` (لندینگ)، `/login`، `/register`، `/dashboard`.
|
||||
- داشبورد، تبها (`VIEW_NAV`): **بازار داخلی**، **کامودیتی**، **فلزات و فولاد** (کاتالوگِ کارت + چارت)، **اقتصاد جهانی** (جدول حرارتی)، **سهام فولادی**، **کریپتو**، **گزارشها**.
|
||||
- زبان بصری: تم **لایتِ کرم + لهجهٔ کهربایی (فولاد مذاب)**؛ توکنها در `styles.css` (oklch). قیمتکارتها سبکِ **Spark-canvas** (نمودار پسزمینه + رقمهای غلتانِ `Odometer` + پیلِ درصد).
|
||||
- API نسبی است (`const API = ""`) تا از طریق **پراکسیِ Vite** هممبدأ کار کند → برای share فقط یک تونل لازم است.
|
||||
- RTL سراسری، فونت **Vazirmatn**. اعداد همه با همین فونت (نه مونو).
|
||||
|
||||
---
|
||||
|
||||
## ۱۱) اشتراکگذاری (ngrok)
|
||||
|
||||
چون `vite.config.ts` مسیرهای `/api` و `/ws` را به `:8000` پراکسی میکند و فرانت از URLهای نسبی استفاده میکند، **فقط یک تونل روی ۵۱۷۴ کافی است**:
|
||||
|
||||
```powershell
|
||||
ngrok http 5174
|
||||
# یا مستقیم باینری: & "$env:APPDATA\npm\node_modules\ngrok\bin\ngrok.exe" http 5174
|
||||
```
|
||||
|
||||
نکته: شیمِ `ngrok.ps1`ی npm گاهی پورت را اشتباه پاس میدهد؛ اگر تونل به پورت غلط رفت، **مستقیم `ngrok.exe`** را صدا بزن. URL را از `http://127.0.0.1:4040/api/tunnels` هم میتوانی بخوانی. (پلنِ رایگان: هر بار URL عوض میشود و یک صفحهٔ هشدارِ اولیه دارد.)
|
||||
|
||||
---
|
||||
|
||||
## ۱۲) امنیت — وضعیت و کارهای واجب
|
||||
|
||||
سختسازیهای انجامشده: CORS محدود، هدرهای امنیتی (`nosniff`, `X-Frame-Options`, `Referrer-Policy`)، اعتبارسنجی + ریتلیمیتِ ورودیها، محدودیتِ کوئریِ `history`/`search`، گاردِ path-traversal روی استریمِ PDF، کوئریهای پارامتری، احراز هویتِ سمتسرور با کوکیِ httpOnly، و حذفِ توکنِ هاردکد از کد.
|
||||
|
||||
**واجب قبل از پروداکشن:**
|
||||
1. توکنِ بالادست (SteelStatista) که قبلاً در git history لو رفته را **باطل/تمدید** کن؛ مقدارِ جدید فقط در پنل ادمین/`env`.
|
||||
2. `ADMIN_PASSWORD` قوی بگذار (پیشفرضِ ضعیف حذف شده).
|
||||
3. در HTTPS واقعی: `COOKIE_SECURE=1` و `ALLOWED_ORIGINS` را به دامنهٔ واقعی محدود کن.
|
||||
4. پنل ادمین (`:8001`) را **عمومی نکن** (در تونل نگذار؛ فقط لوکال/VPN/SSH).
|
||||
|
||||
---
|
||||
|
||||
## ۱۳) دامها و بدهیِ فنی (برای دفعهٔ بعد)
|
||||
|
||||
- `DB_PATH`/مسیرها در `db.py` و `admin_app.py` پیشفرضِ ویندوزیِ مطلق دارند (شاملِ نام کاربری) — بهتره به مسیر نسبی/`env` منتقل شود.
|
||||
- وابستگیهای `package.json` با `^` پین نشدهاند؛ برای بیلدِ تکرارپذیر `npm ci` + پین.
|
||||
- فایلهای `__pycache__/*.pyc` قبلاً کامیت شدهاند؛ untrack کن: `git rm -r --cached backend/__pycache__`.
|
||||
- `routeTree.gen.ts` خودکار است — دستی ویرایش نکن (با اضافهشدن فایل route، خودِ Vite بازتولید میکند).
|
||||
- اسکرپرها به ساختار HTMLِ منابع وابستهاند؛ اگر منبع قالبش را عوض کند، سلکتورها/`wait_for_timeout` باید تنظیم شوند.
|
||||
|
||||
</div>
|
||||
|
|
@ -0,0 +1,706 @@
|
|||
# 🔒 The Comprehensive Security Checklist for Vibe-Coded Apps & AI Agents
|
||||
|
||||
> A merged, expanded checklist built from the **vibe-check** ruleset (17 categories) and **Sherlock Forensics' 7 prompts**, plus the things both of them miss: **anti-scraping**, **API-abuse prevention**, **AI/LLM-agent security**, **infrastructure hardening**, and **monitoring**.
|
||||
>
|
||||
> Goal: make it genuinely hard for a random person — or a bored kid with a script — to break in, scrape your data, or run up your bill.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Read this first (honest reality check)
|
||||
|
||||
There is **no such thing as "unhackable."** Anyone who tells you otherwise is selling something. What you *can* do is raise the cost of attacking you so high that casual attackers move on and serious ones are slowed down and detected. That's the realistic goal: **defense in depth**, not invincibility.
|
||||
|
||||
This checklist gets you past the mistakes that have *actually* taken down real vibe-coded apps in production — none of which required a sophisticated attack. When you have real users and real data, **hire a human penetration tester**. No checklist replaces someone actively trying to break your stuff.
|
||||
|
||||
Two stats worth remembering:
|
||||
- Carnegie Mellon found AI-generated code is ~61% functionally correct but only ~10.5% *secure*. The gap between "it works" and "it's safe" is where you get breached.
|
||||
- Broken access control (someone reading another user's data by changing an ID) has been the #1 web vulnerability for years. It's also the easiest to introduce and the easiest to exploit.
|
||||
|
||||
---
|
||||
|
||||
## How to use this checklist (3 ways)
|
||||
|
||||
This document does three jobs at once. Use all three.
|
||||
|
||||
**1. As rules your AI coding agent reads while it writes (prevention).**
|
||||
Copy the **[Agent Rules](#-agent-rules-paste-into-claudemd--agentsmd)** section at the bottom into your project root as `CLAUDE.md` (Claude Code) or `AGENTS.md` (Cursor, Copilot, Codex, Windsurf, Gemini CLI). Commit it. Your AI reads it automatically from then on and stops creating these vulnerabilities in the first place.
|
||||
|
||||
**2. As an audit your AI agent runs against existing code (detection).**
|
||||
Paste the prompts in **[Copy-Paste AI Audit Prompts](#-copy-paste-ai-audit-prompts)** into your AI tool, one at a time. Tell it to investigate, report what it found, fix it, and verify. Don't batch them — one category at a time, fully, or it skims.
|
||||
|
||||
**3. As manual tests you run yourself (verification).**
|
||||
Work through **[Manual Penetration Tests](#-manual-penetration-tests-do-these-yourself)**. These catch the things AI can't verify from inside the code — like whether your live database is actually queryable from the open internet right now.
|
||||
|
||||
> **If your product itself is an AI agent** (a chatbot, an LLM-powered assistant, an autonomous tool-using agent), pay special attention to **[Section 7: AI / LLM Agent Security](#7--ai--llm-agent-security-the-part-everyone-forgets)**. That's the attack surface that doesn't exist in normal apps and that neither source covers.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 TIER 0 — The five that actually take companies down
|
||||
|
||||
If you do nothing else today, do these five. Every documented vibe-coded breach traces back to one of them. None required a clever attack.
|
||||
|
||||
- [ ] **Database is not publicly queryable.** Row Level Security (RLS) enabled on *every* table, default-deny, policies scoped to the logged-in user. Test it with a raw `curl` using only your public/anon key (see Manual Test #1).
|
||||
- [ ] **Every protected API route rejects unauthenticated requests** with a 401, *before* the handler runs — not checked inside the function where it's easy to forget.
|
||||
- [ ] **No secrets in your git repo.** `.env` is in `.gitignore` *before the first commit*. No API keys, DB passwords, or tokens anywhere in source. Scan git history, not just current files.
|
||||
- [ ] **Users can't access each other's data by changing an ID.** Every route that takes a resource ID verifies `current_user owns that resource` — a *separate* check from "are you logged in."
|
||||
- [ ] **No secret keys in the browser.** Open DevTools → Sources → search for `sk_`, `AKIA`, `secret`, `private_key`. Secret keys live server-side only; the frontend gets *publishable* keys at most.
|
||||
|
||||
Everything below makes you more secure. These five keep you out of the headlines.
|
||||
|
||||
---
|
||||
|
||||
## 1. 🔑 Secrets & Credentials
|
||||
|
||||
The fastest way to get owned: ship a secret key to the browser or commit it to GitHub. Bots scan public repos for leaked keys within *minutes* of a push.
|
||||
|
||||
- [ ] `.env` is listed in `.gitignore` **before** the first commit is ever made.
|
||||
- [ ] `git ls-files .env` returns nothing (the file is not tracked).
|
||||
- [ ] No API keys, DB credentials, or tokens are hardcoded in any source file.
|
||||
- [ ] No secret lives in a client-exposed env var: anything prefixed `NEXT_PUBLIC_`, `VITE_`, or `REACT_APP_` is **bundled into the browser** — only publishable keys belong there.
|
||||
- [ ] `.env.example` exists with **placeholder values only**, never real credentials.
|
||||
- [ ] Git *history* has been scanned for previously-committed secrets (use `gitleaks detect --source . --verbose`). A deleted secret still lives in history.
|
||||
- [ ] **Any key that was ever committed has been rotated.** Once it touches a public repo, treat it as compromised — even if you deleted it seconds later.
|
||||
- [ ] Secrets are loaded server-side only and injected via your host's secret manager in production (Vercel/Netlify/Railway env vars, AWS Secrets Manager, Doppler, etc.), not shipped in a file.
|
||||
|
||||
**Fix pattern:** Move every secret to a server-only environment variable. Proxy any third-party call that needs a secret key through your own backend route so the key never reaches the client.
|
||||
|
||||
---
|
||||
|
||||
## 2. 🗄️ Database Security
|
||||
|
||||
Your database is the prize. If the anon/public key can read tables directly, you have no security at all — the rest of the app is decoration.
|
||||
|
||||
- [ ] **Row Level Security (RLS) is enabled on EVERY table** before deployment (Supabase). No exceptions, including "internal" tables.
|
||||
- [ ] Every table has **explicit policies scoped to the user** (`auth.uid()`), with a default of **deny all**.
|
||||
- [ ] No policy uses `USING (true)` or `FOR ALL` without a real `WHERE` condition. That's an "allow everyone everything" policy in disguise.
|
||||
- [ ] **Firebase rules require `request.auth != null`** and scope access to `request.auth.uid`. The default open ruleset is wide open to the planet.
|
||||
- [ ] A `curl` to your REST endpoint with **only the public/anon key** returns empty or 403 for tables that hold user data (see Manual Test #1).
|
||||
- [ ] Service-role / admin database keys are **never** exposed to the client and never used in frontend code.
|
||||
- [ ] **Never deserialize untrusted data.** No `pickle.loads`/`pickle.load` (Python) or equivalent on anything a user can influence. Use JSON for all network data.
|
||||
- [ ] Database backups are enabled and **the restore process has actually been tested** (a backup you've never restored is a hope, not a backup).
|
||||
- [ ] The DB user your app connects with has **least privilege** — it can't `DROP TABLE` or read tables it doesn't need.
|
||||
|
||||
---
|
||||
|
||||
## 3. 👤 Authentication
|
||||
|
||||
Authentication is the front door. AI builds login flows that *look* complete but skip the security requirements.
|
||||
|
||||
- [ ] Passwords are hashed with **bcrypt, Argon2, or scrypt** (cost factor ≥ 10). **Never** MD5, SHA-1, or plain SHA-256 — those are crackable in bulk if your DB leaks.
|
||||
- [ ] Login, registration, and password-reset endpoints are **rate limited** (see Section 14).
|
||||
- [ ] **JWTs / session tokens expire** (≤ 24 hours for access tokens; use short-lived access + refresh tokens for longer sessions).
|
||||
- [ ] Session tokens are **actually invalidated on logout** — server-side, not just deleted from the browser.
|
||||
- [ ] **Password reset links expire** within ~1 hour and are single-use.
|
||||
- [ ] Session cookies are set with `httpOnly: true`, `secure: true`, and `sameSite: 'lax'` (or `strict`).
|
||||
- [ ] **Multi-factor authentication (MFA/2FA)** is available, and required for admin accounts.
|
||||
- [ ] **Account enumeration is prevented:** login, signup, and password-reset return the *same* message and timing whether or not the email exists ("If that account exists, we sent a link"). Don't tell attackers which emails are registered.
|
||||
- [ ] **New passwords are checked against breach lists** (e.g. Have I Been Pwned's k-anonymity API) and a minimum length is enforced (length beats complexity rules).
|
||||
- [ ] Email/identity is verified before granting full access where it matters.
|
||||
- [ ] After N failed logins, the account or IP is throttled or temporarily locked (with care not to enable a lockout-as-DoS).
|
||||
|
||||
> Using a managed auth provider (Supabase Auth, Auth0, Clerk, Firebase Auth)? Most of the hashing/session mechanics are handled for you — but you still own MFA config, enumeration behavior, session expiry settings, and authorization (Section 4).
|
||||
|
||||
---
|
||||
|
||||
## 4. 🛂 Authorization & Access Control (IDOR)
|
||||
|
||||
**This is the big one.** Authentication asks "who are you?" Authorization asks "are you allowed to touch *this specific thing*?" AI almost always builds the first and forgets the second. Changing `/api/orders/123` to `/api/orders/124` and getting someone else's order is the single most common — and most damaging — vibe-coded vulnerability.
|
||||
|
||||
- [ ] **Every route that takes a resource ID verifies ownership:** `current_user.id == resource.owner_id`. This is a check you write, separate from authentication.
|
||||
- [ ] The ownership check exists on **reads (GET)** *and* **writes (PUT/PATCH/DELETE)** — not just writes. Reading someone else's data is still a breach.
|
||||
- [ ] Failing the ownership check returns **403** (not 404, not the data).
|
||||
- [ ] **Vertical privilege escalation is blocked:** a regular user hitting an admin endpoint gets 403. Admin status is checked server-side on every admin route.
|
||||
- [ ] **Mass assignment / over-posting is prevented:** a user can't set fields like `role: "admin"`, `is_verified: true`, `account_balance: 9999`, or `user_id: <someone_else>` just by adding them to the request body. Whitelist the fields you accept; never blindly spread the request body into your DB model.
|
||||
- [ ] IDs that don't *need* to be guessable use **UUIDs**, not sequential integers (defense in depth — UUIDs don't replace ownership checks, they just make enumeration harder).
|
||||
- [ ] Authorization is enforced at the **API/server layer**, never relying on the frontend hiding a button. Hidden ≠ protected.
|
||||
|
||||
**Fix pattern:** In every handler, after you know who the user is, fetch the resource and explicitly compare ownership before returning or modifying it. Centralize this so it's hard to forget.
|
||||
|
||||
---
|
||||
|
||||
## 5. 🔌 API Security & Abuse Prevention
|
||||
|
||||
> *You specifically asked about people misusing your API. Neither source covers this in depth — here's the dedicated section.*
|
||||
|
||||
An exposed API is an open invitation to automate abuse: scraping every record, hammering expensive endpoints, brute-forcing, or running up your metered bills (OpenAI, Twilio, SMS, etc.). Auth alone doesn't stop a *legitimate* user from abusing the API.
|
||||
|
||||
**Access & authentication**
|
||||
- [ ] Every non-public endpoint requires authentication (API key, OAuth token, or session) — verified server-side before the handler.
|
||||
- [ ] API keys are **scoped** (least privilege — read-only keys exist, keys are tied to specific resources/permissions) and **revocable** instantly.
|
||||
- [ ] Keys can be **rotated** without downtime, and you have a tested procedure to kill a leaked key.
|
||||
- [ ] Keys are hashed at rest in your DB (store a hash, show the raw key only once at creation).
|
||||
|
||||
**Quotas, throttling & cost control**
|
||||
- [ ] **Per-key / per-user rate limits** exist on *all* endpoints, not just login. Tier them (e.g. free vs paid).
|
||||
- [ ] **Hard usage quotas** per key per day/month, so one key can't drain your whole budget.
|
||||
- [ ] **Spend caps / budget alerts** on every metered downstream service (OpenAI, Anthropic, Twilio, SendGrid, cloud egress). Set provider-side billing limits *and* your own circuit breaker that stops calling out when a threshold is hit.
|
||||
- [ ] **Request size limits** (max body size, max upload size) enforced server-side to prevent memory-exhaustion DoS.
|
||||
- [ ] **Result-set caps:** list/search endpoints have a maximum page size and require pagination. No endpoint returns "all rows" — that's a scraping endpoint waiting to happen.
|
||||
|
||||
**Anti-enumeration & data-volume protection**
|
||||
- [ ] Endpoints that return lists **cap how much can be pulled** over a time window (a user pulling 50,000 records in an hour is exfiltration, not usage).
|
||||
- [ ] Sequential/guessable IDs in API responses are avoided where they enable bulk enumeration.
|
||||
- [ ] **GraphQL specifically:** query depth limiting, query cost/complexity analysis, disable introspection in production, and cap aliases/batching (otherwise one request can ask for everything).
|
||||
|
||||
**Monitoring**
|
||||
- [ ] Per-key usage is logged and **anomalies trigger alerts** (sudden 100× spike, access from new geography, hitting endpoints in an automated pattern).
|
||||
- [ ] Abusive keys can be **auto-throttled or suspended** when they cross thresholds.
|
||||
|
||||
**Fix pattern:** Put a rate-limiter + quota layer in front of the API (e.g. Upstash/Redis token bucket, or your gateway's built-in limits). Add a kill-switch env flag that disables expensive downstream calls if spend spikes.
|
||||
|
||||
---
|
||||
|
||||
## 6. 🕷️ Anti-Scraping & Bot Protection
|
||||
|
||||
> *You specifically asked about someone scraping your entire website. Neither source covers this — here's the dedicated section.*
|
||||
|
||||
A motivated kid with a Python script and `requests` can copy your whole site in an afternoon if nothing stops them. You can't make scraping *impossible* (anything a browser can render, a determined bot can read), but you can make it slow, expensive, and detectable so casual scrapers give up.
|
||||
|
||||
**Front-line bot management**
|
||||
- [ ] Put the site behind a provider with **bot management / WAF** (Cloudflare, AWS WAF, Vercel's protections, Fastly). This is the single highest-leverage move — turn on bot-fight mode and managed challenge rules.
|
||||
- [ ] **Rate limit by IP and by user**, site-wide, not just on auth. Burst limits + sustained limits.
|
||||
- [ ] **Challenge suspicious traffic** with CAPTCHA / Turnstile / hCaptcha on high-value or high-volume flows (signup, search, bulk-data pages, password reset).
|
||||
- [ ] **Block or challenge known datacenter / cloud IP ranges and bad ASNs** for sensitive endpoints — most scraping comes from AWS/GCP/Azure/OVH IPs, not residential ones.
|
||||
|
||||
**Make bulk extraction expensive**
|
||||
- [ ] **No endpoint dumps everything.** Paginate, cap page size, and rate-limit pagination so walking all pages takes a long, throttled time.
|
||||
- [ ] Sensitive data (full contact lists, pricing, proprietary content) requires auth and is metered per account.
|
||||
- [ ] Consider **honeypot links/fields** invisible to humans but followed by naive crawlers — hitting them flags the client as a bot.
|
||||
- [ ] Optionally **watermark or canary** your data (unique seeded records) so you can identify who leaked/scraped it later.
|
||||
|
||||
**Hygiene & detection**
|
||||
- [ ] `robots.txt` is configured (won't stop bad actors, but stops well-behaved crawlers and is good hygiene). Don't rely on it for security.
|
||||
- [ ] **Detect automation signals:** missing/!suspicious `User-Agent`, no JS execution, headless-browser fingerprints, impossible click speeds, identical timing patterns. Feed these into your bot rules.
|
||||
- [ ] **Monitor for scraping patterns:** one client touching thousands of distinct resource pages, sequential ID walking, or high request volume with no normal browsing behavior → alert and throttle.
|
||||
- [ ] DDoS protection is enabled at the edge (your CDN/WAF), so a flood can't take you offline or rack up egress costs.
|
||||
|
||||
**Reality check:** Public content that's readable in a browser is ultimately scrapable. The goal is to stop the *casual* "scrape the whole site in one script" attempt and to gate/meter the data that actually matters behind auth + per-account limits.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7. 🤖 AI / LLM Agent Security (the part everyone forgets)
|
||||
|
||||
> *If your product is itself an AI agent — a chatbot, an LLM assistant, a tool-using autonomous agent — this is your unique attack surface. Neither source touches it. This is where "my AI agent gets abused" actually happens.*
|
||||
|
||||
An LLM-powered feature introduces risks that don't exist in normal apps: users can talk the model into ignoring your rules, leaking data, calling tools it shouldn't, or burning your token budget.
|
||||
|
||||
**Prompt injection (the #1 LLM risk)**
|
||||
- [ ] **Treat all model output as untrusted.** Never `eval()` it, never run it as SQL/shell, never auto-execute it without validation. The model can be tricked into emitting malicious instructions.
|
||||
- [ ] **Direct prompt injection:** assume users *will* try "ignore previous instructions." Keep the real authorization/business logic in *your code*, not in the prompt. The system prompt is not a security boundary — server-side checks are.
|
||||
- [ ] **Indirect prompt injection:** if the agent reads external content (web pages, uploaded files, emails, RAG documents), that content can contain hidden instructions. Sandbox it, strip/escape it, and don't let retrieved text silently change the agent's behavior or trigger tool calls.
|
||||
- [ ] System prompt and developer instructions are **not relied on as secrets** — assume users can extract them.
|
||||
|
||||
**Tool / function-calling guardrails (for agents that take actions)**
|
||||
- [ ] The agent operates under **least privilege** — it can only call the specific tools and access the specific data it needs, scoped to the *current user*. It must not be able to read other users' data via a tool.
|
||||
- [ ] **Dangerous actions require confirmation or are off-limits** — sending money, deleting data, emailing arbitrary addresses, running code. Add human-in-the-loop for high-impact actions.
|
||||
- [ ] Tool inputs the model generates are **validated like any other user input** before execution (the model is effectively an untrusted user here).
|
||||
- [ ] If the agent **executes code**, it runs in a locked-down sandbox (no network, no filesystem, no secrets, resource/time limits) — never on your main server with your env vars in scope.
|
||||
- [ ] The agent can't be used as an **SSRF proxy** (fetching internal URLs on the attacker's behalf — see Section 10) or as a **free passthrough to the raw LLM** (your endpoint shouldn't just relay arbitrary prompts to the paid model with no scoping).
|
||||
|
||||
**Cost & abuse control (this is how the "bored kid" runs up your bill)**
|
||||
- [ ] **Per-user token/request quotas and rate limits** on every agent endpoint. One user must not be able to send thousands of requests and drain your OpenAI/Anthropic budget.
|
||||
- [ ] **Max input length and max output tokens** enforced per request.
|
||||
- [ ] **Hard spend cap + alerting** on your LLM provider account, plus a server-side circuit breaker that stops calling the model when a daily threshold is hit.
|
||||
- [ ] Conversation/context length is bounded so a user can't force ever-growing (and ever-more-expensive) context windows.
|
||||
|
||||
**Content, privacy & output safety**
|
||||
- [ ] **Input and output moderation** (provider moderation endpoint or your own filter) to stop the agent generating or amplifying harmful content that gets attributed to you.
|
||||
- [ ] The agent **doesn't leak secrets, system internals, other users' data, or PII** in its responses. Don't put secrets or cross-user data in the context window in the first place.
|
||||
- [ ] **Log agent inputs, tool calls, and outputs** (scrubbing PII) so you can audit abuse and debug incidents.
|
||||
- [ ] Outputs that get rendered in the browser are **escaped/sanitized** — an LLM can be induced to produce `<script>` or markdown that leads to XSS (see Section 9).
|
||||
- [ ] If you fine-tune or use RAG on user data, you've confirmed **one user's data can't surface in another user's responses.**
|
||||
|
||||
**Fix pattern:** Put a thin trusted layer around the model — authenticate the user, enforce quotas, scope tools and data to that user, validate everything the model emits before acting on it, and cap spend. The model is a powerful but untrusted component; your code stays in charge of permissions and money.
|
||||
|
||||
---
|
||||
|
||||
## 8. 💉 Input Validation & Injection
|
||||
|
||||
User input is hostile until proven otherwise. Client-side validation is for UX only — **all validation must happen server-side.**
|
||||
|
||||
- [ ] **All user input is validated server-side** (type, length, format, range). Client-side checks don't count.
|
||||
- [ ] **SQL injection:** every query uses **parameterized queries / prepared statements / ORM methods**. Zero string concatenation, f-strings, `.format()`, or template literals building SQL with user input.
|
||||
- [ ] **NoSQL injection** (MongoDB etc.): user input isn't passed as raw query objects; operators like `$where`/`$gt` can't be injected via request params.
|
||||
- [ ] **Command injection:** user input never reaches `exec`, `system`, `subprocess`, `child_process`, `eval` unsanitized. Avoid shelling out with user data; if unavoidable, use arg arrays, never string interpolation.
|
||||
- [ ] **Path traversal:** file paths built from user input are validated/normalized so `../../etc/passwd` can't escape the intended directory.
|
||||
- [ ] **Open redirect:** redirect targets from user input are validated against an allowlist — don't blindly `redirect(req.query.next)`.
|
||||
- [ ] **SSTI (server-side template injection):** user input is never rendered *as* a template; it's passed as *data* to the template engine.
|
||||
- [ ] **XXE:** XML parsers have external-entity resolution disabled.
|
||||
- [ ] **ReDoS:** user input isn't fed to catastrophic-backtracking regexes; complex regexes are bounded/tested.
|
||||
- [ ] **Request body limits + JSON depth limits** prevent giant or deeply-nested payloads from exhausting memory.
|
||||
|
||||
---
|
||||
|
||||
## 9. 🧬 Output Encoding & XSS
|
||||
|
||||
Cross-site scripting lets an attacker run JavaScript in your users' browsers — stealing sessions, keystrokes, and data.
|
||||
|
||||
- [ ] **No `dangerouslySetInnerHTML`, `v-html`, or `innerHTML`** with user-supplied content unless it's first sanitized with **DOMPurify** (or an equivalent allowlist sanitizer).
|
||||
- [ ] Where raw HTML rendering is genuinely required, it goes through DOMPurify every time.
|
||||
- [ ] **Server-side template auto-escaping is enabled** (the default in most engines — don't disable it).
|
||||
- [ ] User input reflected into HTML, attributes, JS, or URLs is **context-appropriately encoded.**
|
||||
- [ ] A **Content-Security-Policy** is set (Section 13) as a second line of defense — even if XSS slips through, CSP limits what injected script can do.
|
||||
- [ ] **LLM-generated content** rendered to users is treated as untrusted and sanitized too (see Section 7).
|
||||
- [ ] Test it: submit `<script>alert('XSS')</script>` and `<img src=x onerror=alert('XSS')>` into every field. It should render as plain text, never fire (Manual Test #12).
|
||||
|
||||
---
|
||||
|
||||
## 10. 🌐 SSRF (Server-Side Request Forgery)
|
||||
|
||||
If your app fetches URLs the user supplies (link previews, image proxies, "import from URL," webhook testers), an attacker can point it at *internal* addresses — including cloud metadata endpoints that hand out your credentials.
|
||||
|
||||
- [ ] **All private/internal IP ranges are blocked:** `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16` (cloud metadata!), `::1`.
|
||||
- [ ] **Only `http` and `https` schemes are allowed** (no `file://`, `gopher://`, `ftp://`, etc.).
|
||||
- [ ] The hostname is **resolved and the resulting IP is checked *before* the request is made** — and re-checked to prevent DNS-rebinding (don't validate, then fetch a different IP).
|
||||
- [ ] Redirects are not blindly followed to internal addresses.
|
||||
- [ ] This applies to your **AI agent** too — if it can fetch URLs, it inherits all of these requirements (Section 7).
|
||||
- [ ] If your app fetches no user-supplied URLs, mark **N/A** and note why.
|
||||
|
||||
Test it: submit `http://169.254.169.254/latest/meta-data/`, `http://127.0.0.1/`, `http://[::1]/`. All must be rejected before any request fires (Manual Test #6).
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 11. 🎭 CSRF (Cross-Site Request Forgery)
|
||||
|
||||
CSRF tricks a logged-in user's browser into making state-changing requests they didn't intend (e.g. a hidden form on a malicious site that changes their email on *your* site).
|
||||
|
||||
- [ ] Session cookies use **`SameSite=Lax` or `Strict`** (this alone blocks most CSRF), **OR**
|
||||
- [ ] All state-changing endpoints (POST/PUT/PATCH/DELETE) validate a **CSRF token**.
|
||||
- [ ] A cross-origin form POST to any state-changing endpoint **fails** (Manual Test #7).
|
||||
- [ ] State-changing actions are never exposed via GET requests.
|
||||
|
||||
---
|
||||
|
||||
## 12. 🔀 CORS
|
||||
|
||||
Misconfigured CORS lets malicious websites make authenticated requests to your API using your users' credentials.
|
||||
|
||||
- [ ] CORS origin is an **explicit allowlist** of your actual domains — **never `*`**.
|
||||
- [ ] The origin is **not dynamically reflected** back from the request header (that's a wildcard in disguise).
|
||||
- [ ] **`credentials: true` is never combined with a wildcard origin** (the browser blocks it, but the intent is the bug).
|
||||
- [ ] Test it: a request with `Origin: https://evil.com` should not get an `Access-Control-Allow-Origin` echoing it back (Manual Test #9).
|
||||
|
||||
---
|
||||
|
||||
## 13. 🛡️ Security Headers
|
||||
|
||||
Free, browser-enforced protection that AI tools almost never set. Apply them via **one global middleware**, not per-route.
|
||||
|
||||
- [ ] **`Content-Security-Policy`** — start with `default-src 'self'` and loosen as needed. Your strongest defense against XSS.
|
||||
- [ ] **`Strict-Transport-Security: max-age=31536000; includeSubDomains`** — forces HTTPS.
|
||||
- [ ] **`X-Frame-Options: DENY`** — stops clickjacking (your site embedded in a malicious iframe).
|
||||
- [ ] **`X-Content-Type-Options: nosniff`** — stops MIME-sniffing attacks.
|
||||
- [ ] **`Referrer-Policy: strict-origin-when-cross-origin`** — limits leaked referrer data.
|
||||
- [ ] All five are present on **every** response (Express: use **helmet**; Next.js: set them in `next.config.js`).
|
||||
- [ ] Verify with `curl -I https://yourapp.com` or at **securityheaders.com** (Manual Test #8).
|
||||
|
||||
---
|
||||
|
||||
## 14. 🚦 Rate Limiting & DoS Protection
|
||||
|
||||
Without rate limits, attackers brute-force logins, scrape data, and exhaust your resources for free.
|
||||
|
||||
- [ ] **Login, registration, and password-reset** endpoints are rate limited (recommend ~10 attempts per 15 min per IP; return **429** when exceeded).
|
||||
- [ ] **Expensive endpoints** (search, exports, anything calling a paid downstream service or your LLM) are rate limited.
|
||||
- [ ] **All API endpoints** have a sensible default limit (Section 5).
|
||||
- [ ] **`X-Forwarded-For` is not trusted** for rate-limiting unless you're behind a known, trusted reverse proxy — otherwise attackers spoof it to bypass limits.
|
||||
- [ ] Edge-level **DDoS protection** is enabled (Cloudflare/WAF/CDN).
|
||||
- [ ] Rate limiting is enforced **server-side / at the gateway**, not in client code.
|
||||
- [ ] Test it: 50 rapid failed logins should start returning 429 (Manual Test #10).
|
||||
|
||||
---
|
||||
|
||||
## 15. 📎 File Uploads
|
||||
|
||||
Uploads are a classic foothold: a "JPG" that's actually a script, executed on your origin.
|
||||
|
||||
- [ ] File type is validated by **magic bytes (actual file content)**, not the filename extension.
|
||||
- [ ] Uploads are **renamed to UUIDs server-side** (no user-controlled filenames, no path traversal via filename).
|
||||
- [ ] Files are stored on a **separate domain/bucket** (S3, R2, GCS) — **never** served from your app's origin, so an uploaded file can't run as same-origin script.
|
||||
- [ ] **Size limits are enforced server-side** (not just a frontend attribute).
|
||||
- [ ] Images are re-encoded/processed server-side where feasible (strips embedded payloads and EXIF).
|
||||
- [ ] Uploaded files are **scanned for malware** where the risk warrants it.
|
||||
- [ ] Test it: upload a `.jpg` containing `<script>alert('XSS')</script>` and visit its URL — it must not execute on your origin (Manual Test #14).
|
||||
- [ ] No uploads? Mark **N/A**.
|
||||
|
||||
---
|
||||
|
||||
## 16. 💳 Payments & Webhooks (Stripe)
|
||||
|
||||
A webhook endpoint that trusts whatever hits it can be told "payment succeeded" by anyone with `curl`.
|
||||
|
||||
- [ ] Stripe webhook signature is **verified on every request** with `stripe.Webhook.constructEvent` (or your language's equivalent). Invalid/missing signature → **400**.
|
||||
- [ ] Webhook handlers are **idempotent** — processed event IDs are stored and duplicates are skipped (Stripe retries; double-processing = double-granting).
|
||||
- [ ] The **full event lifecycle** is handled, not just success: `payment_intent.succeeded`, `invoice.payment_failed`, `customer.subscription.deleted`, `customer.subscription.past_due`.
|
||||
- [ ] Entitlements (what a user gets) are driven by **verified webhook events / server-side state**, never by a client-side "payment worked" redirect.
|
||||
- [ ] Amounts and prices are **set/validated server-side** — the client can't submit its own price.
|
||||
- [ ] Test it: POST a fake event with no signature — it must return 400, not 200 (Manual Test #13).
|
||||
- [ ] No payments? Mark **N/A**.
|
||||
|
||||
---
|
||||
|
||||
## 17. 🤐 Error Handling & Information Disclosure
|
||||
|
||||
Verbose errors hand attackers a map: your DB schema, file paths, framework versions, and internal logic.
|
||||
|
||||
- [ ] A **global error handler** catches all unhandled exceptions.
|
||||
- [ ] Production responses return **generic messages only** (`{"error": "Something went wrong"}`) — no stack traces, SQL errors, file paths, or library names.
|
||||
- [ ] Full error detail goes to **server-side logs only**.
|
||||
- [ ] **Debug/development mode is OFF in production** (Django `DEBUG=False`, Flask debug off, framework dev pages disabled).
|
||||
- [ ] **Source maps are not deployed** to production (they expose your original source).
|
||||
- [ ] Custom **404 / 403 / 500** pages are configured (no framework default error pages).
|
||||
- [ ] Errors return **consistent JSON + appropriate HTTP status codes**.
|
||||
- [ ] Test it: send invalid JSON, non-existent IDs, a lone `'` — responses must stay generic (Manual Test #15).
|
||||
|
||||
---
|
||||
|
||||
## 18. 📦 Dependencies & Supply Chain
|
||||
|
||||
AI tools **hallucinate package names**. Attackers pre-register those exact fake names with malware ("slopsquatting"). Installing one gives an attacker code execution on your server.
|
||||
|
||||
- [ ] **Every dependency the AI suggested is verified to actually exist** on the official registry (npm/PyPI/Packagist) with real download history and a maintained repo — *before* you install it.
|
||||
- [ ] Package names are checked for **typosquatting** (`reqeusts` vs `requests`, `lodahs` vs `lodash`).
|
||||
- [ ] **Red flags investigated:** < ~1,000 weekly downloads, published in the last 30 days, single unnamed maintainer, name suspiciously close to a popular package.
|
||||
- [ ] **Exact versions are pinned** in production (no `^` or `~`).
|
||||
- [ ] **Lock files are committed** (`package-lock.json`, `poetry.lock`, `yarn.lock`).
|
||||
- [ ] `npm audit` / `pip-audit` shows **no critical or high** vulnerabilities (run it in CI).
|
||||
- [ ] Unused/unnecessary dependencies are removed (smaller attack surface; some things are built-in language features).
|
||||
- [ ] Automated dependency scanning is enabled (Dependabot / Renovate / Snyk).
|
||||
|
||||
---
|
||||
|
||||
## 19. 🏗️ Infrastructure, Deployment & Secrets Management
|
||||
|
||||
The app can be perfect and still fall to a misconfigured server or an over-privileged cloud key.
|
||||
|
||||
- [ ] **HTTPS/TLS everywhere**, valid cert, HTTP → HTTPS redirect, HSTS on (Section 13).
|
||||
- [ ] Production secrets come from a **secrets manager / host env vars**, not a file in the repo (Section 1).
|
||||
- [ ] **Separate environments** for dev / staging / production with **separate credentials** — no shared keys, no prod data in dev.
|
||||
- [ ] Cloud **IAM follows least privilege** — services and keys can only touch what they need. No `AdministratorAccess` on app roles.
|
||||
- [ ] **Storage buckets are private by default** (the classic "public S3 bucket" leak); public access is explicit and intentional.
|
||||
- [ ] Admin panels, databases, and internal dashboards are **not exposed to the public internet** (VPN / IP allowlist / auth).
|
||||
- [ ] Default credentials on **everything** (databases, admin tools, services) are changed.
|
||||
- [ ] Server OS and runtime are **patched / on supported versions**.
|
||||
- [ ] CI/CD secrets are stored in the platform's secret store, and CI logs don't print them.
|
||||
- [ ] If you use containers, base images are scanned and kept updated.
|
||||
|
||||
---
|
||||
|
||||
## 20. 📊 Logging, Monitoring & Incident Response
|
||||
|
||||
You can't respond to what you can't see. Most breaches are detected late — or by the customer.
|
||||
|
||||
- [ ] **Security events are logged:** failed logins, authz failures (403s), rate-limit hits, admin actions, key creation/revocation.
|
||||
- [ ] Logs **never contain secrets, passwords, full tokens, or unmasked PII**.
|
||||
- [ ] **Alerts fire on anomalies:** spikes in errors/429s/403s, spend spikes on metered services, logins from new geographies, bulk data access (Sections 5, 6).
|
||||
- [ ] Logs are **centralized and retained** (you can reconstruct what happened after an incident).
|
||||
- [ ] You have a basic **incident-response plan**: how to revoke/rotate every key, take the app offline, notify users, and preserve evidence.
|
||||
- [ ] **Everything can be rotated quickly** — DB credentials, API keys, signing secrets, OAuth secrets. Know how *before* you need to.
|
||||
- [ ] A `security.txt` / responsible-disclosure contact exists so researchers can report issues to you instead of dumping them publicly.
|
||||
|
||||
---
|
||||
|
||||
## 21. 🔐 Data Protection & Privacy
|
||||
|
||||
- [ ] Data is **encrypted in transit** (TLS) and **at rest** (DB/storage-level encryption).
|
||||
- [ ] **PII is minimized** — you collect and retain only what you actually need.
|
||||
- [ ] Sensitive data has a **retention + secure-deletion policy** (and deletion actually deletes, including from backups over time).
|
||||
- [ ] Users can **export and delete their data** where required (GDPR/CCPA), and you honor it.
|
||||
- [ ] **Audit logs** record access to sensitive records.
|
||||
- [ ] Third parties you send data to are vetted; you're not leaking PII to analytics/LLM providers unintentionally.
|
||||
- [ ] Backups are **encrypted** and access-controlled (a leaked backup is a full breach).
|
||||
|
||||
|
||||
---
|
||||
|
||||
# 📋 Copy-Paste AI Audit Prompts
|
||||
|
||||
Paste these into your AI coding agent **one at a time**. After each, tell it to: *investigate every relevant file, report what's vulnerable with severity, fix it, then verify the fix.* Don't batch — one category fully, or it skims.
|
||||
|
||||
> **Kickoff prompt:**
|
||||
> *"Run a security audit on this project. Go through each of the prompts I give you one at a time. For each: investigate thoroughly (every file, route, config, migration, env file, frontend), write a report of what's vulnerable / safe / missing with severity (CRITICAL/HIGH/MEDIUM/LOW/PASS), make a fix plan, implement it, and verify against concrete goals. Put results in a `security/` folder. Do not move on until the current category is fully done."*
|
||||
|
||||
### Prompt — Secrets exposure
|
||||
```
|
||||
Scan the entire codebase and git history for hardcoded secrets, API keys, and credentials. Find: strings matching key patterns (sk_, pk_, key_, token_, secret_, AKIA...), DB connection strings with embedded passwords, private keys/certs in code, .env files tracked by git, and any secret living in a client-exposed env var (NEXT_PUBLIC_*, VITE_*, REACT_APP_*). Confirm .env is in .gitignore and `git ls-files .env` is empty. For each finding: file, line, and the secure fix using a server-side env var. Flag any committed secret as needing rotation.
|
||||
```
|
||||
|
||||
### Prompt — Database access (RLS)
|
||||
```
|
||||
Audit database access control. For Supabase: list every table and confirm Row Level Security is enabled with explicit policies scoped to auth.uid() and a default-deny stance; flag any policy using USING(true) or FOR ALL without a WHERE condition. For Firebase: confirm rules require request.auth != null and scope to request.auth.uid. Confirm no service-role/admin DB key is used in frontend code. Show me which tables a request with only the anon/public key could read, and the fix for each.
|
||||
```
|
||||
|
||||
### Prompt — Authentication
|
||||
```
|
||||
Review authentication for vulnerabilities. Check: password hashing uses bcrypt/argon2/scrypt (cost >= 10) and never MD5/SHA-1/SHA-256; rate limiting exists on login/register/password-reset; JWT/session tokens expire (<= 24h access) and are invalidated server-side on logout; password reset links expire within 1h and are single-use; session cookies set httpOnly+secure+sameSite; MFA is available and required for admins; account enumeration is prevented (identical response/timing for existing vs non-existing emails on login/signup/reset). List each issue with severity and the exact code fix.
|
||||
```
|
||||
|
||||
### Prompt — Authorization / access control (IDOR)
|
||||
```
|
||||
Audit every API endpoint for authorization flaws (this is the highest-risk category). For each endpoint that takes a resource ID (in path, query, or body), confirm the handler verifies the authenticated user owns that resource (current_user.id == resource.owner_id) as a SEPARATE check from authentication, on BOTH reads and writes, returning 403 on failure. Check for horizontal escalation (User A reading User B's data by changing an ID) and vertical escalation (regular user hitting admin endpoints). Check for mass-assignment/over-posting (user setting fields like role, is_admin, balance, or user_id via the request body). List every endpoint, its current authorization status, and the fix.
|
||||
```
|
||||
|
||||
### Prompt — API abuse & rate limiting
|
||||
```
|
||||
Review the API for abuse resistance. For every endpoint check: is there per-user/per-key rate limiting and a usage quota; are there hard caps on result-set size with required pagination (no endpoint returns all rows); are request body sizes limited; can sequential IDs be enumerated to scrape data; for any metered downstream service (LLM, SMS, email, cloud), is there a spend cap and circuit breaker? Confirm X-Forwarded-For isn't trusted for rate limiting unless behind a trusted proxy. For GraphQL: check query depth/cost limits and disabled introspection in production. List gaps and fixes.
|
||||
```
|
||||
|
||||
### Prompt — AI / LLM agent security (if applicable)
|
||||
```
|
||||
If this project exposes an LLM/AI agent, audit it for AI-specific risks. Check: model output is never executed (no eval/SQL/shell) without validation; business logic and authorization live in code, not in the prompt; external/retrieved content (RAG, web, files, uploads) is treated as untrusted to prevent indirect prompt injection; tool/function calls are scoped to the current user with least privilege and dangerous actions require confirmation; any code execution is sandboxed with no secrets/network; per-user token and request quotas + max input/output length + an LLM spend cap and circuit breaker exist; input/output moderation is in place; the agent can't leak secrets, system prompts, PII, or other users' data; agent outputs rendered in the browser are sanitized. List each gap with severity and fix.
|
||||
```
|
||||
|
||||
### Prompt — Injection (SQL / command / path / etc.)
|
||||
```
|
||||
Audit all database queries and any use of system/exec/eval for injection. Find every query built with string concatenation, f-strings, .format(), or template literals containing user input and convert to parameterized queries/prepared statements. Check for NoSQL injection (Mongo operator injection), ORM raw-query misuse, command injection (user input reaching exec/subprocess/child_process), path traversal in file operations, open redirects, SSTI, and XXE. Show vulnerable code and the fixed version side by side.
|
||||
```
|
||||
|
||||
### Prompt — XSS / output encoding
|
||||
```
|
||||
Find every place user-supplied (or LLM-generated) content is rendered. Flag all uses of dangerouslySetInnerHTML, v-html, and innerHTML with unsanitized content and add DOMPurify where raw HTML is required. Confirm server-side template auto-escaping is on. Confirm a Content-Security-Policy exists as defense-in-depth. Show each risky render and its fix.
|
||||
```
|
||||
|
||||
### Prompt — SSRF
|
||||
```
|
||||
Find any code that fetches a URL derived from user input (link previews, image proxies, import-from-URL, webhook testers, or an AI agent's URL fetching). Confirm it blocks private IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1), allows only http/https, and resolves+checks the IP before requesting (guarding against DNS rebinding). If none exists, say so. Show fixes for any gap.
|
||||
```
|
||||
|
||||
### Prompt — CSRF, CORS & security headers
|
||||
```
|
||||
Check three things. CSRF: are session cookies SameSite=Lax/Strict, or do all state-changing endpoints validate a CSRF token? CORS: is origin an explicit allowlist (never * or reflected), and is credentials:true never paired with a wildcard? Headers: are Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy set on every response via one global middleware (helmet / next.config.js)? List what's missing and the exact config.
|
||||
```
|
||||
|
||||
### Prompt — Anti-scraping & bot protection
|
||||
```
|
||||
Review the app's resistance to automated scraping. Check: is there site-wide per-IP and per-user rate limiting (not just on auth); do list/search/data endpoints cap page size and require pagination so bulk extraction is slow; is sensitive data gated behind auth and metered per account; are there CAPTCHA/challenge points on high-value flows; is there bot/WAF protection at the edge; are datacenter IP ranges challenged on sensitive endpoints; is there monitoring/alerting for scraping patterns (sequential ID walking, high-volume single-client access)? List what's missing and recommend concrete additions.
|
||||
```
|
||||
|
||||
### Prompt — Payments / webhooks (if applicable)
|
||||
```
|
||||
Audit Stripe (or other) payment handling. Confirm webhook signatures are verified on every request (invalid/missing -> 400); processed event IDs are stored for idempotency so duplicates are skipped; the full event lifecycle is handled (payment_intent.succeeded, invoice.payment_failed, customer.subscription.deleted, past_due); entitlements derive from verified server-side events, not a client redirect; and prices/amounts are validated server-side. List gaps and fixes.
|
||||
```
|
||||
|
||||
### Prompt — File uploads (if applicable)
|
||||
```
|
||||
Audit file upload handling. Confirm: file type is validated by magic bytes not extension; files are renamed to UUIDs server-side; files are stored on a separate domain/bucket (S3/R2/GCS), never the app origin; size limits are enforced server-side; images are re-encoded to strip payloads/EXIF. Show fixes for any gap.
|
||||
```
|
||||
|
||||
### Prompt — Error handling & deployment
|
||||
```
|
||||
Review error handling and production config for information disclosure. Confirm: a global error handler returns only generic messages to clients (no stack traces, SQL errors, file paths, or library names); full detail is logged server-side only; debug/dev mode is OFF in production; source maps are not deployed; custom 404/403/500 pages exist; errors return consistent JSON with correct status codes. Also confirm HTTPS with HSTS and that no secrets are hardcoded. Show every place internals could leak and the secure pattern for this framework.
|
||||
```
|
||||
|
||||
### Prompt — Dependencies & supply chain
|
||||
```
|
||||
Audit all dependencies (package.json / requirements.txt / pyproject.toml / composer.json). For each: confirm it exists on the official registry with real download history and a maintained repo; check for typosquatting and slopsquatting (names close to popular packages, suspiciously new packages, < ~1000 weekly downloads, single unnamed maintainer); confirm exact versions are pinned and lock files committed; run npm audit / pip-audit and report critical/high issues; flag unused or unnecessary dependencies. List anything suspicious.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 🧪 Manual Penetration Tests (do these yourself)
|
||||
|
||||
These verify the things AI can't confirm from inside the code — your *live* app's actual behavior. Do them before real users arrive. **If you only do five, do tests 1–5.**
|
||||
|
||||
### 1. Database isn't publicly queryable
|
||||
Hit your REST endpoint with only your public/anon key (both are visible in your frontend):
|
||||
```bash
|
||||
curl "https://YOUR_PROJECT.supabase.co/rest/v1/users?select=*" -H "apikey: YOUR_ANON_KEY"
|
||||
```
|
||||
✅ PASS: empty array or permission error. ❌ FAIL: returns real user data.
|
||||
(Firebase: try reading a collection while unauthenticated.)
|
||||
|
||||
### 2. Protected routes reject unauthenticated requests
|
||||
In DevTools → Network, copy a request to a protected endpoint while logged in. Log out. Replay it without the session cookie/auth header.
|
||||
✅ PASS: 401. ❌ FAIL: returns data.
|
||||
Also: call an admin-only endpoint with a regular user session → should be **403**.
|
||||
|
||||
### 3. No secrets in git
|
||||
```bash
|
||||
git ls-files .env # PASS: no output
|
||||
grep "\.env" .gitignore # PASS: shows a match
|
||||
gitleaks detect --source . --verbose # PASS: no leaks (scans history too)
|
||||
```
|
||||
|
||||
### 4. Can't access another user's data by changing an ID
|
||||
Create User A and User B. As User A, request a resource that belongs to User B:
|
||||
```
|
||||
GET /api/resources/{user_b_id} -> should return 403
|
||||
PUT /api/resources/{user_b_id} -> should return 403
|
||||
```
|
||||
Test *every* endpoint that takes a user-scoped ID (documents, orders, payments, profile). ❌ FAIL: you get B's data.
|
||||
|
||||
### 5. No secret keys in the browser
|
||||
DevTools → Sources → search all files for `sk_`, `AKIA`, `Bearer`, `secret`, `private_key`. Then Network tab → watch for secret tokens sent from the client to third-party APIs.
|
||||
✅ PASS: nothing. ❌ FAIL: any match.
|
||||
|
||||
### 6. Internal URLs are blocked (SSRF)
|
||||
In any feature that fetches a user-supplied URL, submit:
|
||||
```
|
||||
http://127.0.0.1/ http://localhost/ http://[::1]/
|
||||
http://10.0.0.1/ http://169.254.169.254/latest/meta-data/
|
||||
```
|
||||
✅ PASS: all rejected before any request. ❌ FAIL: any returns content. (Skip if no URL-fetching feature.)
|
||||
|
||||
### 7. Cross-origin form POSTs are blocked (CSRF)
|
||||
While logged into your app, open an HTML file containing a hidden auto-submitting form that POSTs to one of your state-changing endpoints (e.g. change-email) from a different origin.
|
||||
✅ PASS: action fails (403 / no effect). ❌ FAIL: it executes.
|
||||
|
||||
### 8. Security headers are present
|
||||
```bash
|
||||
curl -I https://yourapp.com | grep -i "content-security-policy\|strict-transport\|x-frame-options\|x-content-type"
|
||||
```
|
||||
✅ PASS: all present. Or check **securityheaders.com**.
|
||||
|
||||
### 9. CORS isn't wide open
|
||||
```bash
|
||||
curl -I -H "Origin: https://evil.com" https://yourapp.com/api/anything | grep -i "access-control-allow-origin"
|
||||
```
|
||||
✅ PASS: no header or your domain only. ❌ FAIL: `*` or echoes `https://evil.com`.
|
||||
|
||||
### 10. Login can't be brute-forced
|
||||
```bash
|
||||
for i in $(seq 1 50); do
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://yourapp.com/api/login \
|
||||
-H "Content-Type: application/json" -d '{"email":"test@test.com","password":"wrong"}'
|
||||
done
|
||||
```
|
||||
✅ PASS: starts returning 429 after a few. ❌ FAIL: all 50 return 401 unthrottled.
|
||||
|
||||
### 11. SQL injection doesn't work
|
||||
In login, search, and filter inputs submit: `' OR '1'='1` and `'; DROP TABLE users; --`
|
||||
✅ PASS: no unexpected data, no DB error leaked. ❌ FAIL: unexpected data or a SQL error appears.
|
||||
|
||||
### 12. XSS doesn't execute
|
||||
In every text input and URL param submit: `<script>alert('XSS')</script>` and `<img src=x onerror=alert('XSS')>`
|
||||
✅ PASS: shown as plain text. ❌ FAIL: an alert fires.
|
||||
|
||||
### 13. Stripe webhooks reject fakes
|
||||
```bash
|
||||
curl -X POST https://yourapp.com/api/webhook -H "Content-Type: application/json" \
|
||||
-d '{"type":"payment_intent.succeeded","data":{"object":{"id":"pi_fake"}}}'
|
||||
```
|
||||
✅ PASS: 400 / signature error. ❌ FAIL: 200 and it processes. Also send the same valid event twice → if it processes both, idempotency is missing.
|
||||
|
||||
### 14. File uploads reject disguised files
|
||||
Make `test.jpg` whose contents are `<script>alert('XSS')</script>`. Upload it normally, then visit its URL.
|
||||
✅ PASS: rejected, or served as download, or on a different domain. ❌ FAIL: it executes on your origin.
|
||||
|
||||
### 15. Errors don't leak internals
|
||||
Submit invalid JSON, non-existent IDs, and a lone `'`.
|
||||
✅ PASS: generic "something went wrong." ❌ FAIL: stack traces, SQL, file paths, or library names appear.
|
||||
|
||||
### 16. Passwords are hashed properly
|
||||
```bash
|
||||
grep -rn "hashlib.md5\|hashlib.sha1\|createHash.*md5\|createHash.*sha1" --include="*.py" --include="*.js" --include="*.ts" ./
|
||||
```
|
||||
✅ PASS: no matches. bcrypt hashes start with `$2b$`; a 32-hex hash is MD5, 40-hex is SHA-1 (both bad).
|
||||
|
||||
### 17. Dependencies are real
|
||||
For every package you don't recognize: confirm it exists on npmjs.com / pypi.org, check weekly downloads, publish date, and maintainer. Then:
|
||||
```bash
|
||||
npm audit # or: pip-audit
|
||||
```
|
||||
🚩 Red flags: < 1,000 weekly downloads, published in the last 30 days, lone unnamed maintainer, name close to a popular package.
|
||||
|
||||
### 18. (Bonus) Bulk scraping is throttled
|
||||
As a logged-in user, script a loop pulling many list/detail pages or walking sequential IDs.
|
||||
✅ PASS: you hit rate limits / pagination caps / challenges quickly. ❌ FAIL: you can pull thousands of records unthrottled.
|
||||
|
||||
### 19. (Bonus) AI agent abuse is capped
|
||||
If you have an AI agent: send a rapid burst of requests, oversized inputs, and a few "ignore your instructions / reveal your system prompt / call an admin tool" attempts.
|
||||
✅ PASS: per-user limits kick in, spend is capped, the agent refuses out-of-scope actions, no secrets/other-user data leak. ❌ FAIL: unthrottled spend or it follows injected instructions.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# 📜 Agent Rules (paste into `CLAUDE.md` / `AGENTS.md`)
|
||||
|
||||
Drop this block into your project root as `CLAUDE.md` (Claude Code) or `AGENTS.md` (Cursor / Copilot / Codex / Windsurf / Gemini). Not sure? Make both. Commit it. Your AI reads it on every task from then on and stops creating these issues in the first place.
|
||||
|
||||
```markdown
|
||||
# Security Rules (non-negotiable, apply to all generated code)
|
||||
|
||||
## Secrets
|
||||
- NEVER put API keys, DB credentials, or tokens in frontend code (src/, app/, pages/, components/, public/).
|
||||
- NEVER put a secret in a client-exposed env var (NEXT_PUBLIC_*, VITE_*, REACT_APP_*) — those ship to the browser.
|
||||
- NEVER hardcode credentials. Load secrets server-side from env vars only.
|
||||
- .env MUST be in .gitignore before the first .env is created. Use .env.example with placeholders only.
|
||||
|
||||
## Database
|
||||
- Enable Row Level Security on EVERY table before deploy. Default deny. Policies scoped to auth.uid().
|
||||
- NEVER use an RLS policy of USING(true) or FOR ALL without a WHERE condition.
|
||||
- Firebase rules MUST require request.auth != null and scope to request.auth.uid.
|
||||
- Service-role / admin DB keys NEVER appear in client code.
|
||||
- NEVER deserialize untrusted data (no pickle.loads on user input). Use JSON for network data.
|
||||
|
||||
## Auth & authorization
|
||||
- EVERY protected route gets auth middleware that runs BEFORE the handler. Unauthenticated -> 401.
|
||||
- EVERY route taking a resource ID MUST verify ownership (current_user.id == resource.owner_id) as a SEPARATE check, on reads AND writes. Fail -> 403.
|
||||
- Admin routes verify admin role -> 403 for non-admins.
|
||||
- Prevent mass assignment: whitelist accepted fields; never spread the request body into a DB model. Users can't set role/is_admin/balance/user_id.
|
||||
- Session cookies: httpOnly:true, secure:true, sameSite:'lax'. Tokens expire and are invalidated server-side on logout.
|
||||
- Passwords: bcrypt/argon2/scrypt only. NEVER MD5/SHA-1/SHA-256.
|
||||
- Don't leak account existence on login/signup/reset (identical responses).
|
||||
|
||||
## Input / output
|
||||
- NEVER concatenate user input into SQL. ALWAYS parameterized queries / ORM methods.
|
||||
- NEVER use dangerouslySetInnerHTML/v-html/innerHTML with unsanitized content. Sanitize with DOMPurify.
|
||||
- ALL validation server-side (client-side is UX only). Validate type/length/format/range.
|
||||
- No command injection (no user input into exec/subprocess/eval), no path traversal, no open redirects, no SSTI/XXE.
|
||||
- Enforce request body size limits.
|
||||
|
||||
## URL fetching (SSRF)
|
||||
- If fetching user-supplied URLs: block 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1; allow only http/https; resolve and check the IP BEFORE the request.
|
||||
|
||||
## API & abuse
|
||||
- Per-user/per-key rate limits + quotas on ALL endpoints, not just auth.
|
||||
- List/search endpoints: max page size + required pagination. No endpoint returns all rows.
|
||||
- Spend caps + circuit breakers on every metered downstream service (LLM, SMS, email, cloud).
|
||||
- Don't trust X-Forwarded-For for rate limiting unless behind a trusted proxy.
|
||||
|
||||
## AI / LLM agent (if present)
|
||||
- Treat model output as untrusted: never eval/SQL/shell it without validation.
|
||||
- Keep authorization and business logic in code, not the prompt. Assume users can extract the system prompt.
|
||||
- Treat retrieved/external content as untrusted (indirect prompt injection). Scope tools to the current user with least privilege. Dangerous actions need confirmation. Sandbox any code execution (no secrets/network).
|
||||
- Enforce per-user token/request quotas, max input/output length, and an LLM spend cap. Moderate input and output. Sanitize agent output rendered in the browser.
|
||||
|
||||
## Headers / CORS / CSRF
|
||||
- Set CSP, Strict-Transport-Security, X-Frame-Options:DENY, X-Content-Type-Options:nosniff, Referrer-Policy on ALL responses via one global middleware (helmet / next.config.js).
|
||||
- CORS origin = explicit allowlist. NEVER '*', never reflected, never wildcard + credentials:true.
|
||||
- Session cookies SameSite=Lax/Strict OR CSRF tokens on all state-changing endpoints.
|
||||
|
||||
## Files / payments / errors / deps
|
||||
- Uploads: validate by magic bytes, rename to UUID, store on a separate bucket/domain, enforce size server-side.
|
||||
- Stripe webhooks: verify signature every request (-> 400 if bad), idempotent via stored event IDs, handle failure events. Entitlements from verified server-side events, not client redirects.
|
||||
- Errors: global handler, generic client messages only, full detail to server logs, debug OFF in production, no source maps in prod.
|
||||
- Dependencies: verify each package exists on the official registry (guard against typo/slopsquatting) BEFORE installing. Pin exact versions. Commit lock files.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 🎯 Where to start (priority order)
|
||||
|
||||
You don't have to do everything at once. In order:
|
||||
|
||||
1. **TIER 0 (the five).** Today. These are what get apps breached.
|
||||
2. **Sections 1–4** — secrets, database, auth, access control. The foundation.
|
||||
3. **Sections 5–7** — API abuse, anti-scraping, AI agent. Your three stated concerns.
|
||||
4. **Sections 8–18** — injection, XSS, SSRF, CSRF, CORS, headers, rate limiting, uploads, payments, errors, dependencies.
|
||||
5. **Sections 19–21** — infrastructure, monitoring, data protection. The operational layer that catches what code review misses.
|
||||
6. **Run the manual tests** against your live app. Then re-run after every significant change.
|
||||
|
||||
---
|
||||
|
||||
# 🧠 Final word
|
||||
|
||||
This checklist closes the gap between "the AI made it work" and "the AI made it safe" — and it covers the two things off-the-shelf checklists skip: **scraping** and **API/cost abuse**, plus the **AI-agent attack surface** if your product is an agent.
|
||||
|
||||
But repeat after me: **there is no unhackable.** What you've built here is a high wall with cameras — enough to send casual attackers and bored kids looking for an easier target, and enough to detect and slow down the serious ones. When you have real users and real revenue, **hire a penetration tester.** A human actively trying to break your app will find things no checklist can, and that's worth far more than the cost.
|
||||
|
||||
Ship it locked down. Then keep watching the logs.
|
||||
|
||||
---
|
||||
|
||||
*Built by merging the vibe-check ruleset (MIT) and Sherlock Forensics' security prompts, expanded with anti-scraping, API-abuse, AI/LLM-agent, infrastructure, and monitoring coverage. Sources documented real breaches across 5,600+ scanned vibe-coded apps. This is a strong first pass — not a substitute for a professional audit.*
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
# SECURITY.md — Security Rules for This Project
|
||||
|
||||
> **To the AI agent reading this:** These are **mandatory, non-negotiable rules**. Follow them on every change you make, even when the user doesn't mention security. When a requested feature conflicts with a rule here, implement the secure version and tell the user what you did and why. If you are unsure whether something is safe, **stop and ask** rather than guessing. Never disable a security control to "make it work" — fix the underlying cause instead.
|
||||
|
||||
This file exists because apps built fast (including AI-assisted ones) repeatedly ship the same handful of vulnerabilities. The goal is an app that is "immune" to the common classes of attack by **default**, not as an afterthought.
|
||||
|
||||
---
|
||||
|
||||
## 0. The Golden Rules (read these first)
|
||||
|
||||
1. **The frontend is public.** Anything in client-side code, network responses, or the browser is visible to and editable by an attacker. Never trust it. All real security must live on the server / database.
|
||||
2. **Never trust input.** Validate, sanitize, and authorize *every* input on the server — query params, headers, body, file uploads, cookies, and webhook payloads.
|
||||
3. **Authenticate, then authorize, on every request.** Logging in is not enough. Check *this specific user is allowed to do this specific action on this specific resource* — server-side, every time.
|
||||
4. **Secrets stay on the server.** No keys, tokens, passwords, or connection strings in frontend code, repos, or logs. Ever.
|
||||
5. **Fail closed.** When something is ambiguous or errors out, deny access. Default to the most restrictive option.
|
||||
6. **Least privilege everywhere.** Every key, role, token, and DB account gets the minimum permissions it needs and nothing more.
|
||||
|
||||
---
|
||||
|
||||
## 1. Secrets & Credentials
|
||||
|
||||
**NEVER**
|
||||
- Hardcode API keys, passwords, JWT secrets, DB URLs, or private tokens anywhere in source.
|
||||
- Put secrets in frontend/client code or in any `NEXT_PUBLIC_`, `VITE_`, `REACT_APP_`, or otherwise client-exposed env var.
|
||||
- Commit `.env`, `.env.local`, credential files, `*.pem`, or service-account JSON to git.
|
||||
- Log secrets, tokens, or full request bodies that may contain them.
|
||||
- Reuse the same secret across dev/staging/prod.
|
||||
|
||||
**ALWAYS**
|
||||
- Load secrets from server-side environment variables only.
|
||||
- Add `.env*`, `*.pem`, `*.key`, `secrets/`, and credential files to `.gitignore` *before* the first commit.
|
||||
- Provide a `.env.example` with key **names only** and dummy values.
|
||||
- Use a distinct, high-entropy (32+ random bytes) value for each signing/encryption secret.
|
||||
- Assume any secret that ever touched the frontend or a public repo is **compromised** — rotate it.
|
||||
- Use a secrets manager (or the platform's encrypted env store) in production, not files on disk.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication
|
||||
|
||||
**NEVER**
|
||||
- Roll your own crypto, password hashing, or session logic when a vetted library/provider exists.
|
||||
- Store passwords in plaintext or with fast/weak hashes (MD5, SHA1, unsalted SHA256).
|
||||
- Trust a `userId`, `role`, `isAdmin`, or `email` value that came from the client to decide who someone is.
|
||||
- Put auth state only in `localStorage` and treat its presence as proof of identity.
|
||||
- Leave default/seed credentials (`admin/admin`) in any environment.
|
||||
- Return "user not found" vs "wrong password" differently (enables user enumeration).
|
||||
|
||||
**ALWAYS**
|
||||
- Use a maintained auth library/provider (e.g. the platform's auth, Auth.js, Lucia, Clerk, Supabase Auth) and follow its server-side session verification.
|
||||
- Hash passwords with bcrypt, scrypt, or Argon2 with a proper cost factor.
|
||||
- Verify the session/token **on the server** for every protected request and derive identity from it — never from request body params.
|
||||
- Enforce a password policy + check against known-breached passwords where possible.
|
||||
- Implement account lockout / throttling on repeated failed logins.
|
||||
- Offer/encourage MFA for sensitive accounts.
|
||||
- Expire sessions, rotate on privilege change, and invalidate on logout server-side.
|
||||
|
||||
---
|
||||
|
||||
## 3. Authorization (the #1 vibe-coded flaw)
|
||||
|
||||
> Most rapidly-built apps authenticate but barely authorize. This is where IDOR, broken access control, and data leaks come from.
|
||||
|
||||
**NEVER**
|
||||
- Hide a button/route on the frontend and call it "protected." UI hiding is not access control.
|
||||
- Use sequential/guessable IDs as the *only* thing protecting a resource (`/api/orders/1043` → an attacker just tries `1044`). This is **IDOR**.
|
||||
- Assume that because the UI only sends valid requests, the server only receives valid requests.
|
||||
- Let one user's token read/modify another user's data because the query didn't filter by owner.
|
||||
|
||||
**ALWAYS**
|
||||
- On every data access, check ownership/permission server-side: `WHERE owner_id = :current_user` (or equivalent), not just `WHERE id = :id`.
|
||||
- Re-check role/permission for every privileged action on the server, on every call.
|
||||
- Enforce authorization at the **lowest layer possible** (DB row-level security + API layer), so a missed check in one place doesn't expose everything.
|
||||
- Use unguessable IDs (UUIDs/ULIDs) for resources, *and still* authorize them.
|
||||
- Centralize authorization logic so it's consistent and auditable, not copy-pasted per route.
|
||||
|
||||
---
|
||||
|
||||
## 4. Database Security (Supabase / Firebase / direct DB)
|
||||
|
||||
> Vibe-coded apps frequently expose the DB straight to the browser with permissive rules. This is catastrophic.
|
||||
|
||||
**NEVER**
|
||||
- Ship Supabase/Postgres tables with **Row Level Security (RLS) disabled** on anything holding user data.
|
||||
- Use Firebase/Firestore rules like `allow read, write: if true;` or test-mode rules in production.
|
||||
- Expose a service-role / admin DB key to the client (the anon/public key is the only client-side key, and even that needs RLS behind it).
|
||||
- Build SQL by string concatenation or template interpolation with user input.
|
||||
- Run app queries as a superuser/admin DB account.
|
||||
|
||||
**ALWAYS**
|
||||
- Enable RLS on every table and write explicit policies scoped to the authenticated user.
|
||||
- Write Firestore/RTDB rules that check `request.auth` and validate ownership and shape.
|
||||
- Use parameterized queries / prepared statements / a query builder / ORM bindings — **always** (prevents SQL injection).
|
||||
- Give the app a least-privilege DB role (no DROP/ALTER, only needed tables).
|
||||
- Validate data shape and types at the DB boundary, not just the UI.
|
||||
- Keep the service-role key strictly server-side for trusted admin operations.
|
||||
|
||||
---
|
||||
|
||||
## 5. Input Validation & Injection
|
||||
|
||||
**NEVER**
|
||||
- Pass user input directly into SQL, shell commands, `eval`, template engines, `dangerouslySetInnerHTML`, file paths, or `Function()`.
|
||||
- Rely on frontend validation as a security boundary (it's UX only).
|
||||
- Build file paths from user input without normalizing (enables **path traversal**, `../../etc/passwd`).
|
||||
|
||||
**ALWAYS**
|
||||
- Validate every input server-side against a strict schema (e.g. Zod, Pydantic, Joi) — type, length, format, allowed values.
|
||||
- Allow-list rather than block-list whenever possible.
|
||||
- Use parameterized queries for SQL and safe APIs for OS/command operations (avoid shelling out with user input at all).
|
||||
- Reject unexpected fields to prevent **mass assignment** (don't blindly spread `req.body` into a DB record — pick allowed fields explicitly).
|
||||
- Canonicalize and confine file paths to an intended directory.
|
||||
|
||||
---
|
||||
|
||||
## 6. Output / XSS / Content
|
||||
|
||||
**NEVER**
|
||||
- Render untrusted content as raw HTML (`dangerouslySetInnerHTML`, `innerHTML`, `v-html`, `{{{ }}}`) without sanitizing.
|
||||
- Reflect user input into responses unescaped.
|
||||
- Inject user-controlled values into `<script>`, inline event handlers, or URLs (`javascript:`).
|
||||
|
||||
**ALWAYS**
|
||||
- Rely on framework auto-escaping (React/Vue/Svelte escape by default — keep it that way).
|
||||
- Sanitize any HTML you *must* render with a maintained sanitizer (e.g. DOMPurify) and a strict allow-list.
|
||||
- Set a strong **Content-Security-Policy** to limit script sources.
|
||||
- Encode output for the correct context (HTML, attribute, JS, URL).
|
||||
|
||||
---
|
||||
|
||||
## 7. APIs, CORS & Rate Limiting
|
||||
|
||||
**NEVER**
|
||||
- Set `Access-Control-Allow-Origin: *` together with credentials, or wildcard CORS on authenticated APIs.
|
||||
- Ship public endpoints with no rate limiting (enables brute force, scraping, credential stuffing, cost-blowup, and DoS).
|
||||
- Leave debug/admin/internal endpoints reachable in production.
|
||||
- Return different responses that leak whether a record exists when it shouldn't.
|
||||
|
||||
**ALWAYS**
|
||||
- Restrict CORS to an explicit allow-list of known origins.
|
||||
- Rate-limit by IP and by user/account on auth, write, and expensive endpoints; add exponential backoff/lockout for auth.
|
||||
- Require auth on every non-public endpoint and verify it server-side.
|
||||
- Validate `Content-Type` and reject unexpected methods.
|
||||
- Protect state-changing requests against **CSRF** (SameSite cookies + CSRF tokens, or token-based auth not stored in cookies).
|
||||
- Treat webhooks as untrusted: verify signatures before acting.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sessions, Tokens & JWTs
|
||||
|
||||
**NEVER**
|
||||
- Accept JWTs with `alg: none` or let the client choose the algorithm.
|
||||
- Sign JWTs with a weak/guessable/shared secret.
|
||||
- Issue tokens with no expiry, or store sensitive long-lived tokens in `localStorage` (XSS-readable).
|
||||
- Trust JWT claims without verifying the signature server-side.
|
||||
|
||||
**ALWAYS**
|
||||
- Pin the expected algorithm and verify the signature on every request.
|
||||
- Use short-lived access tokens + rotating refresh tokens.
|
||||
- Prefer `httpOnly`, `Secure`, `SameSite` cookies for session tokens.
|
||||
- Include and validate `exp`, `iss`, `aud`; revoke on logout/compromise.
|
||||
|
||||
---
|
||||
|
||||
## 9. File Uploads
|
||||
|
||||
**NEVER**
|
||||
- Trust the client-provided filename, extension, or MIME type.
|
||||
- Store uploads in a web-served directory where they can be executed.
|
||||
- Allow arbitrary file types or unbounded file sizes.
|
||||
|
||||
**ALWAYS**
|
||||
- Validate type by content (magic bytes), enforce a size limit, and allow-list extensions.
|
||||
- Generate a new random server-side filename; never use the user's path.
|
||||
- Store outside the web root or in object storage with no-execute and least-privilege access.
|
||||
- Scan uploads where feasible and serve them from a separate domain/sandbox with `Content-Disposition`.
|
||||
|
||||
---
|
||||
|
||||
## 10. HTTP Security Headers & Transport
|
||||
|
||||
**ALWAYS** set (or have the framework/host set) at minimum:
|
||||
- `Strict-Transport-Security` (HSTS) — and enforce HTTPS everywhere; redirect HTTP→HTTPS.
|
||||
- `Content-Security-Policy` — restrict scripts/styles/connect sources.
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: DENY` (or CSP `frame-ancestors`) to prevent clickjacking.
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
- Sensible `Permissions-Policy`.
|
||||
|
||||
**NEVER** serve auth or sensitive data over plain HTTP, or mix HTTP content into an HTTPS app.
|
||||
|
||||
---
|
||||
|
||||
## 11. Error Handling & Logging
|
||||
|
||||
**NEVER**
|
||||
- Return stack traces, SQL errors, file paths, or internal details to the client.
|
||||
- Log passwords, tokens, full card numbers, or other secrets/PII.
|
||||
- Run production with debug mode on.
|
||||
|
||||
**ALWAYS**
|
||||
- Return generic error messages to users; log detail server-side with correlation IDs.
|
||||
- Log security-relevant events (auth failures, access denials, privilege changes) for audit.
|
||||
- Scrub/redact sensitive fields before logging.
|
||||
|
||||
---
|
||||
|
||||
## 12. Dependencies & Supply Chain
|
||||
|
||||
**NEVER**
|
||||
- Add a dependency you don't need, or pull random unvetted packages for a one-liner.
|
||||
- Ignore known-vulnerable dependency warnings.
|
||||
|
||||
**ALWAYS**
|
||||
- Run `npm audit` / `pip-audit` / `osv-scanner` (or equivalent) and fix high/critical issues.
|
||||
- Pin versions and use a lockfile; keep dependencies updated.
|
||||
- Verify package names carefully (watch for typosquats).
|
||||
- Enable automated dependency security updates (e.g. Dependabot) where possible.
|
||||
|
||||
---
|
||||
|
||||
## 13. Sensitive Data & Privacy
|
||||
|
||||
**NEVER**
|
||||
- Collect or store more personal data than the feature needs.
|
||||
- Store sensitive data (PII, payment, health) unencrypted at rest.
|
||||
- Send sensitive data to third-party services/analytics without need and consent.
|
||||
|
||||
**ALWAYS**
|
||||
- Encrypt sensitive data at rest and in transit.
|
||||
- Apply least-privilege access to PII; mask/redact in UIs and logs.
|
||||
- Offboard payment handling to a PCI-compliant provider (e.g. Stripe) rather than touching card data yourself.
|
||||
- Provide data deletion/export paths where regulations require.
|
||||
|
||||
---
|
||||
|
||||
## 14. AI / LLM-Specific (if the app calls an LLM)
|
||||
|
||||
**NEVER**
|
||||
- Concatenate untrusted user input directly into a system prompt and treat the model output as trusted (**prompt injection**).
|
||||
- Give the model unrestricted tools/DB/shell access driven by user-controlled text.
|
||||
- Expose your LLM provider API key to the client.
|
||||
|
||||
**ALWAYS**
|
||||
- Treat model output as untrusted input — validate and authorize any action it triggers.
|
||||
- Keep the LLM key server-side and put your own rate limits + spend caps around it.
|
||||
- Separate trusted instructions from untrusted user content; constrain tool use with allow-lists and per-action authorization.
|
||||
- Filter/limit what data the model can retrieve based on the requesting user's permissions.
|
||||
|
||||
---
|
||||
|
||||
## 15. Server-Side Request & Redirect Safety
|
||||
|
||||
**NEVER**
|
||||
- Fetch a user-supplied URL from the server without restriction (**SSRF** — can hit internal metadata endpoints / private network).
|
||||
- Redirect to a user-supplied URL without validation (**open redirect** — aids phishing).
|
||||
|
||||
**ALWAYS**
|
||||
- Allow-list domains/schemes for any server-side fetch; block private/internal IP ranges and metadata IPs.
|
||||
- Validate redirect targets against an allow-list or use relative paths only.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Ship Security Checklist
|
||||
|
||||
Run through this before any deploy. Treat unchecked items as blockers.
|
||||
|
||||
- [ ] No secrets in code, repo history, logs, or client bundles; `.env*` gitignored; keys rotated if ever exposed.
|
||||
- [ ] Auth verified server-side on every protected route; passwords hashed with bcrypt/argon2; no default creds.
|
||||
- [ ] Every data read/write is authorized by ownership/role server-side (no IDOR); privileged actions re-checked.
|
||||
- [ ] RLS / DB rules enabled and scoped per-user; service-role key server-side only; no `if true` rules.
|
||||
- [ ] All queries parameterized; all input validated server-side against a schema; mass assignment prevented.
|
||||
- [ ] Output escaped/sanitized; CSP set; no unsanitized raw-HTML rendering.
|
||||
- [ ] CORS locked to known origins; rate limiting on auth/write/expensive endpoints; CSRF handled; webhooks signature-verified.
|
||||
- [ ] JWTs verify signature + pinned alg + expiry; session cookies `httpOnly`/`Secure`/`SameSite`.
|
||||
- [ ] File uploads validated by content, size-limited, randomly named, stored no-execute.
|
||||
- [ ] Security headers + HTTPS enforced (HSTS, nosniff, frame-ancestors, referrer-policy).
|
||||
- [ ] Generic error messages to users; debug off in prod; no secrets/PII in logs.
|
||||
- [ ] Dependency audit clean (no high/critical); lockfile committed.
|
||||
- [ ] Sensitive data minimized + encrypted; payments via compliant provider.
|
||||
- [ ] LLM key server-side; model output treated as untrusted; SSRF/open-redirect protections in place.
|
||||
|
||||
---
|
||||
|
||||
## How to use this file with the AI agent
|
||||
|
||||
- Keep this file at the project root as `SECURITY.md` and reference it in your `CLAUDE.md` / agent instructions: *"Follow all rules in SECURITY.md on every change."*
|
||||
- When asking for a feature, you can add: *"Implement this per SECURITY.md and flag any rule it touches."*
|
||||
- Periodically ask: *"Audit the current codebase against SECURITY.md and list violations by severity."*
|
||||
- If the agent ever proposes weakening a control for convenience, that's a red flag — ask for a secure alternative instead.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Admin panel image. Build context = project ROOT (needs backend/db.py + admin/).
|
||||
# docker build -f admin/Dockerfile -t didvan-admin .
|
||||
# Keep this OFF public networks (it exposes user PII + the data-source token).
|
||||
# Base image from Liara's mirror — Docker Hub is blocked from Iran builds.
|
||||
FROM docker-mirror.liara.ir/library/python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONUTF8=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
DB_PATH=/data/asianmetal.db
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# admin only needs a few libs (no Playwright/Chromium)
|
||||
RUN pip install --no-cache-dir fastapi "uvicorn[standard]" pydantic requests python-multipart
|
||||
|
||||
# admin_app imports nothing from backend, but copy both so paths line up
|
||||
COPY admin ./admin
|
||||
COPY backend ./backend
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 8001
|
||||
CMD ["python", "-m", "uvicorn", "admin_app:app", "--host", "0.0.0.0", "--port", "8001", "--app-dir", "admin"]
|
||||
|
|
@ -0,0 +1,696 @@
|
|||
"""
|
||||
Didvan — standalone admin panel (independent FastAPI app, default port 8001).
|
||||
|
||||
Run: uvicorn admin_app:app --app-dir admin --port 8001
|
||||
Auth: HTTP Basic. Set ADMIN_USER / ADMIN_PASSWORD env vars (defaults below).
|
||||
|
||||
Three sections:
|
||||
- کاربران : registered users (name / email / phone) from the `users` table
|
||||
- توکن منبع داده : view & update the scraping token (stored in app_config; sync.py reads it)
|
||||
- سلامت سرویسها : last-scrape recency per service + "test scrape" that validates the
|
||||
SteelStatista token by fetching a sample symbol's latest price
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
import sqlite3
|
||||
import requests
|
||||
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File, Form, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from pydantic import BaseModel
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", r"C:\Users\DATIS STAR\asianmetal.db")
|
||||
# Where report PDFs live — must match the backend's REPORTS_DIR so uploads show
|
||||
# up in the dashboard's reports tab (on the VPS both point at the shared /data disk).
|
||||
REPORTS_DIR = os.environ.get("REPORTS_DIR", r"C:\Users\DATIS STAR\Desktop\asianmetal_reports")
|
||||
MAX_PDF_BYTES = 30 * 1024 * 1024
|
||||
ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
|
||||
# No insecure default — the admin panel exposes user PII + the data-source token,
|
||||
# so a password MUST be provided via the environment (fail closed).
|
||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
|
||||
if not ADMIN_PASSWORD:
|
||||
raise RuntimeError(
|
||||
"ADMIN_PASSWORD environment variable is required (no default). "
|
||||
"Set it before starting the admin app, e.g. $env:ADMIN_PASSWORD='a-strong-secret'."
|
||||
)
|
||||
DATA_API_BASE = "https://api.SteelStatista.com"
|
||||
CATEGORY_IDS = [
|
||||
9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
||||
31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53,
|
||||
54, 55, 56, 57, 65, 67, 68, 70, 71, 72, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85,
|
||||
87, 88, 89, 90, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105,
|
||||
]
|
||||
|
||||
app = FastAPI(title="Didvan Admin")
|
||||
security = HTTPBasic()
|
||||
|
||||
|
||||
def require_admin(creds: HTTPBasicCredentials = Depends(security)) -> str:
|
||||
ok = secrets.compare_digest(creds.username, ADMIN_USER) and secrets.compare_digest(
|
||||
creds.password, ADMIN_PASSWORD
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized", headers={"WWW-Authenticate": "Basic"})
|
||||
return creds.username
|
||||
|
||||
|
||||
def conn():
|
||||
c = sqlite3.connect(DB_PATH, timeout=10)
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("PRAGMA journal_mode=WAL")
|
||||
return c
|
||||
|
||||
|
||||
def _get_token():
|
||||
try:
|
||||
with conn() as c:
|
||||
row = c.execute("SELECT value FROM app_config WHERE key='asianmetal_token'").fetchone()
|
||||
if row and row["value"]:
|
||||
return row["value"]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _age_minutes(ts):
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
return round((datetime.now(timezone.utc) - dt).total_seconds() / 60, 1)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ───────────────────────────── APIs ─────────────────────────────
|
||||
|
||||
@app.get("/api/users")
|
||||
def api_users(_: str = Depends(require_admin)):
|
||||
with conn() as c:
|
||||
rows = c.execute(
|
||||
"SELECT id, name, email, phone, organization, "
|
||||
"COALESCE(status,'pending') AS status, created_at "
|
||||
"FROM users ORDER BY (COALESCE(status,'pending')='pending') DESC, id DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _set_user_status(uid: int, status: str, _: str = Depends(require_admin)):
|
||||
with conn() as c:
|
||||
cur = c.execute("UPDATE users SET status=? WHERE id=?", (status, uid))
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
|
||||
return {"ok": True, "id": uid, "status": status}
|
||||
|
||||
|
||||
@app.post("/api/users/{uid}/approve")
|
||||
def api_approve(uid: int, admin: str = Depends(require_admin)):
|
||||
return _set_user_status(uid, "approved", admin)
|
||||
|
||||
|
||||
@app.post("/api/users/{uid}/reject")
|
||||
def api_reject(uid: int, admin: str = Depends(require_admin)):
|
||||
# rejecting also kills any active session so access is revoked immediately
|
||||
with conn() as c:
|
||||
c.execute("DELETE FROM sessions WHERE user_id=?", (uid,))
|
||||
return _set_user_status(uid, "rejected", admin)
|
||||
|
||||
|
||||
class TokenIn(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@app.get("/api/token")
|
||||
def api_token(_: str = Depends(require_admin)):
|
||||
t = _get_token()
|
||||
masked = (t[:14] + "…" + t[-10:]) if t and len(t) > 30 else (t or "")
|
||||
updated = None
|
||||
try:
|
||||
with conn() as c:
|
||||
row = c.execute("SELECT value FROM app_config WHERE key='asianmetal_token_updated'").fetchone()
|
||||
updated = row["value"] if row else None
|
||||
except Exception:
|
||||
pass
|
||||
return {"set": bool(t), "masked": masked, "updated_at": updated}
|
||||
|
||||
|
||||
@app.post("/api/token")
|
||||
def api_set_token(data: TokenIn, _: str = Depends(require_admin)):
|
||||
tok = (data.token or "").strip()
|
||||
if not tok:
|
||||
return {"ok": False, "error": "توکن خالی است"}
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
with conn() as c:
|
||||
c.execute(
|
||||
"INSERT INTO app_config(key,value) VALUES('asianmetal_token',?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(tok,),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT INTO app_config(key,value) VALUES('asianmetal_token_updated',?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(now,),
|
||||
)
|
||||
return {"ok": True, "updated_at": now}
|
||||
|
||||
|
||||
def _count_change(c, table):
|
||||
try:
|
||||
row = c.execute(f"SELECT MAX(fetched_at) m, COUNT(*) n FROM {table}").fetchone()
|
||||
return row["n"], row["m"]
|
||||
except Exception:
|
||||
return 0, None
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def api_health(_: str = Depends(require_admin)):
|
||||
"""Service health. TGJU services use a heartbeat (last completed scrape cycle),
|
||||
NOT MAX(fetched_at) — because dedup means the table only changes when a price
|
||||
moves, so a quiet market would otherwise look like a dead scraper."""
|
||||
out = []
|
||||
with conn() as c:
|
||||
hb = None
|
||||
try:
|
||||
row = c.execute("SELECT value FROM app_config WHERE key='tgju_last_scrape'").fetchone()
|
||||
hb = row["value"] if row else None
|
||||
except Exception:
|
||||
pass
|
||||
hb_age = _age_minutes(hb)
|
||||
|
||||
# TGJU bundle (currency/gold/coin) — freshness from the shared heartbeat
|
||||
for table, label in [
|
||||
("currency_prices", "بازار داخلی — ارز"),
|
||||
("gold_prices", "بازار داخلی — طلا"),
|
||||
("coin_prices", "بازار داخلی — سکه"),
|
||||
]:
|
||||
n, change = _count_change(c, table)
|
||||
out.append({
|
||||
"key": table, "label": label, "last": hb, "age_min": hb_age,
|
||||
"count": n, "ok": hb_age is not None and hb_age <= 3, "changed": change,
|
||||
})
|
||||
|
||||
# Commodity (Trading Economics) — its own scraper, table timestamp is fine
|
||||
n, change = _count_change(c, "te_prices")
|
||||
c_age = _age_minutes(change)
|
||||
out.append({
|
||||
"key": "commodity", "label": "کامودیتی", "last": change, "age_min": c_age,
|
||||
"count": n, "ok": c_age is not None and c_age <= 90, "changed": change,
|
||||
})
|
||||
|
||||
# Metals & steel — daily batch via sync.py (SteelStatista)
|
||||
try:
|
||||
row = c.execute("SELECT MAX(date) m, COUNT(*) n FROM prices").fetchone()
|
||||
out.append({
|
||||
"key": "metals", "label": "فلزات و فولاد (روزانه)", "last": row["m"],
|
||||
"age_min": None, "count": row["n"], "ok": True, "daily": True,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _yz_headers(tok):
|
||||
return {
|
||||
"Authorization": f"Bearer {tok}",
|
||||
"accept": "application/json",
|
||||
"lang": "fa",
|
||||
"platform": "web",
|
||||
"referer": "https://SteelStatista.com/",
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/test-scrape")
|
||||
def api_test_scrape(_: str = Depends(require_admin)):
|
||||
"""Validate the data-source token by fetching latest prices for a random
|
||||
sample of ~12 symbols across random categories."""
|
||||
tok = _get_token()
|
||||
if not tok:
|
||||
return {"ok": False, "error": "توکنی تنظیم نشده — اول توکن را در تب «توکن» ذخیره کنید."}
|
||||
headers = _yz_headers(tok)
|
||||
try:
|
||||
pool = []
|
||||
for cid in random.sample(CATEGORY_IDS, min(6, len(CATEGORY_IDS))):
|
||||
try:
|
||||
r = requests.get(f"{DATA_API_BASE}/Symbol?categoryId={cid}", headers=headers, timeout=12)
|
||||
if r.status_code == 401:
|
||||
return {"ok": False, "error": "توکن منقضی یا نامعتبر است (401) — باید عوضش کنی."}
|
||||
r.raise_for_status()
|
||||
pool.extend(r.json() or [])
|
||||
except Exception:
|
||||
continue
|
||||
if not pool:
|
||||
return {"ok": False, "error": "هیچ نمادی برنگشت."}
|
||||
items = []
|
||||
for s in random.sample(pool, min(12, len(pool))):
|
||||
try:
|
||||
r2 = requests.get(f"{DATA_API_BASE}/Symbol/{s['id']}", headers=headers, timeout=12)
|
||||
r2.raise_for_status()
|
||||
prices = r2.json().get("symbolPrices", [])
|
||||
if not prices:
|
||||
continue
|
||||
last = max(prices, key=lambda p: p["date"])
|
||||
items.append({
|
||||
"title": s.get("title"),
|
||||
"date": last["date"][:10],
|
||||
"mid": last.get("mid"),
|
||||
"low": last.get("low"),
|
||||
"high": last.get("high"),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
if not items:
|
||||
return {"ok": False, "error": "قیمتی دریافت نشد."}
|
||||
items.sort(key=lambda x: x["title"] or "")
|
||||
return {"ok": True, "count": len(items), "items": items}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"خطا در اتصال به منبع داده: {e}"}
|
||||
|
||||
|
||||
_DATASETS = {
|
||||
"currency": ("currency_prices", "بازار داخلی — ارز", "fetched_at"),
|
||||
"gold": ("gold_prices", "بازار داخلی — طلا/نقره", "fetched_at"),
|
||||
"coin": ("coin_prices", "بازار داخلی — سکه", "fetched_at"),
|
||||
"commodity": ("te_prices", "کامودیتی", "fetched_at"),
|
||||
"metals": ("prices", "فلزات و فولاد", "date"),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/reports")
|
||||
def api_reports(_: str = Depends(require_admin)):
|
||||
out = []
|
||||
with conn() as c:
|
||||
for key, (table, label, datecol) in _DATASETS.items():
|
||||
try:
|
||||
row = c.execute(
|
||||
f"SELECT COUNT(*) n, MIN({datecol}) mn, MAX({datecol}) mx FROM {table}"
|
||||
).fetchone()
|
||||
out.append({"key": key, "label": label, "table": table,
|
||||
"count": row["n"], "first": row["mn"], "last": row["mx"]})
|
||||
except Exception:
|
||||
out.append({"key": key, "label": label, "table": table,
|
||||
"count": 0, "first": None, "last": None})
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/export")
|
||||
def api_export(dataset: str, _: str = Depends(require_admin)):
|
||||
import csv
|
||||
import io
|
||||
from fastapi.responses import Response
|
||||
if dataset not in _DATASETS:
|
||||
raise HTTPException(status_code=404, detail="dataset not found")
|
||||
table, _label, datecol = _DATASETS[dataset]
|
||||
with conn() as c:
|
||||
if dataset == "metals":
|
||||
rows = c.execute(
|
||||
"SELECT grp, category, title, date, low, mid, high FROM prices "
|
||||
"WHERE date = (SELECT MAX(date) FROM prices) ORDER BY grp, category, title"
|
||||
).fetchall()
|
||||
else:
|
||||
rows = c.execute(
|
||||
f"SELECT * FROM {table} WHERE {datecol} = (SELECT MAX({datecol}) FROM {table})"
|
||||
).fetchall()
|
||||
buf = io.StringIO()
|
||||
if rows:
|
||||
w = csv.writer(buf)
|
||||
w.writerow(rows[0].keys())
|
||||
for r in rows:
|
||||
w.writerow([r[k] for k in r.keys()])
|
||||
return Response(
|
||||
content="" + buf.getvalue(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{dataset}_latest.csv"'},
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────── Report PDF upload ───────────────────────
|
||||
|
||||
def _ensure_api_usage_table(c):
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS api_usage_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partner TEXT,
|
||||
token_hash TEXT,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
query TEXT,
|
||||
status_code INTEGER,
|
||||
client_ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_api_usage_created ON api_usage_logs(created_at)")
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_api_usage_partner ON api_usage_logs(partner)")
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_api_usage_path ON api_usage_logs(path)")
|
||||
|
||||
|
||||
@app.get("/api/api-usage")
|
||||
def api_usage(limit: int = Query(100, ge=1, le=500), _: str = Depends(require_admin)):
|
||||
with conn() as c:
|
||||
_ensure_api_usage_table(c)
|
||||
summary = c.execute("""
|
||||
SELECT
|
||||
COALESCE(partner, 'unknown') AS partner,
|
||||
COUNT(*) AS requests,
|
||||
SUM(CASE WHEN status_code BETWEEN 200 AND 399 THEN 1 ELSE 0 END) AS ok,
|
||||
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) AS errors,
|
||||
MAX(created_at) AS last_seen
|
||||
FROM api_usage_logs
|
||||
GROUP BY COALESCE(partner, 'unknown')
|
||||
ORDER BY requests DESC, partner
|
||||
""").fetchall()
|
||||
rows = c.execute("""
|
||||
SELECT id, partner, substr(token_hash, 1, 12) AS token_hash_prefix,
|
||||
method, path, query, status_code, client_ip, user_agent, created_at
|
||||
FROM api_usage_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""", (limit,)).fetchall()
|
||||
return {"summary": [dict(r) for r in summary], "rows": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
def _safe_folder(folder: str):
|
||||
"""One path segment, no traversal, filesystem-safe."""
|
||||
f = (folder or "").strip().replace("\\", "/").split("/")[0].strip(". ")
|
||||
f = re.sub(r'[<>:"|?*\x00-\x1f]', "", f)
|
||||
return f or None
|
||||
|
||||
|
||||
@app.get("/api/report-folders")
|
||||
def report_folders(_: str = Depends(require_admin)):
|
||||
base = os.path.realpath(REPORTS_DIR)
|
||||
if not os.path.isdir(base):
|
||||
return {"dir": REPORTS_DIR, "folders": []}
|
||||
out = []
|
||||
for e in os.scandir(base):
|
||||
if e.is_dir():
|
||||
n = sum(1 for f in os.scandir(e.path) if f.is_file() and f.name.lower().endswith(".pdf"))
|
||||
out.append({"name": e.name, "count": n})
|
||||
out.sort(key=lambda x: x["name"])
|
||||
return {"dir": REPORTS_DIR, "folders": out}
|
||||
|
||||
|
||||
@app.post("/api/upload-report")
|
||||
async def upload_report(folder: str = Form(...), file: UploadFile = File(...), _: str = Depends(require_admin)):
|
||||
safe = _safe_folder(folder)
|
||||
if not safe:
|
||||
raise HTTPException(status_code=400, detail="نام پوشه نامعتبر است")
|
||||
name = os.path.basename(file.filename or "")
|
||||
if not name.lower().endswith(".pdf"):
|
||||
raise HTTPException(status_code=400, detail="فقط فایل PDF مجاز است")
|
||||
name = re.sub(r"[^0-9A-Za-z ._()\--ۿ]", "_", name)
|
||||
data = await file.read()
|
||||
if len(data) > MAX_PDF_BYTES:
|
||||
raise HTTPException(status_code=400, detail="حجم فایل بیش از ۳۰ مگابایت است")
|
||||
if data[:5] != b"%PDF-": # validate by content, not just extension
|
||||
raise HTTPException(status_code=400, detail="محتوای فایل PDF معتبر نیست")
|
||||
base = os.path.realpath(REPORTS_DIR)
|
||||
dest_dir = os.path.realpath(os.path.join(base, safe))
|
||||
if dest_dir != base and not dest_dir.startswith(base + os.sep):
|
||||
raise HTTPException(status_code=400, detail="مسیر نامعتبر")
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
with open(os.path.join(dest_dir, name), "wb") as fh:
|
||||
fh.write(data)
|
||||
return {"ok": True, "folder": safe, "name": name, "size": len(data)}
|
||||
|
||||
|
||||
# ───────────────────────────── UI ─────────────────────────────
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def home(_: str = Depends(require_admin)):
|
||||
return _HTML
|
||||
|
||||
|
||||
_HTML = """<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>پنل ادمین دیدوان</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--bg:#f5f6fb;--surface:#fff;--ink:#1d2330;--muted:#5b6172;--border:#e4e6ef;--primary:#5b4bdb;--ok:#16a34a;--bad:#dc2626;--warn:#d97706;}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-family:Vazirmatn,system-ui,sans-serif;background:var(--bg);color:var(--ink);}
|
||||
header{background:var(--surface);border-bottom:1px solid var(--border);padding:16px 24px;display:flex;align-items:center;gap:10px;position:sticky;top:0;z-index:5}
|
||||
.logo{width:34px;height:34px;border-radius:9px;background:rgba(91,75,219,.12);border:1px solid rgba(91,75,219,.35);display:grid;place-items:center;color:var(--primary);font-weight:700}
|
||||
h1{font-size:16px;margin:0}
|
||||
.sub{font-size:11px;color:var(--muted)}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:24px}
|
||||
.tabs{display:flex;gap:6px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:6px;width:fit-content;margin-bottom:20px}
|
||||
.tab{padding:8px 18px;border:0;background:none;border-radius:8px;font:inherit;font-weight:600;color:var(--muted);cursor:pointer}
|
||||
.tab.active{background:var(--primary);color:#fff}
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px;margin-bottom:16px;box-shadow:0 1px 2px rgba(16,24,40,.04)}
|
||||
h2{font-size:15px;margin:0 0 14px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th,td{text-align:right;padding:10px 12px;border-bottom:1px solid var(--border)}
|
||||
th{font-size:11px;color:var(--muted);font-weight:600}
|
||||
td.mono,th.mono{font-family:ui-monospace,monospace;direction:ltr;text-align:left}
|
||||
.btn{background:var(--primary);color:#fff;border:0;border-radius:9px;padding:10px 18px;font:inherit;font-weight:600;cursor:pointer}
|
||||
.btn:hover{filter:brightness(1.06)}
|
||||
.btn.ghost{background:var(--surface);color:var(--ink);border:1px solid var(--border)}
|
||||
textarea{width:100%;min-height:90px;border:1px solid var(--border);border-radius:10px;padding:12px;font-family:ui-monospace,monospace;font-size:12px;direction:ltr}
|
||||
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:12px}
|
||||
.pill{font-size:11px;font-family:ui-monospace,monospace;padding:3px 9px;border-radius:999px;background:#eef0f8;color:var(--muted)}
|
||||
.hidden{display:none}
|
||||
.svc{display:flex;justify-content:space-between;align-items:center;padding:13px 0;border-bottom:1px solid var(--border)}
|
||||
.svc:last-child{border-bottom:0}
|
||||
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-left:8px}
|
||||
.dot.ok{background:var(--ok)} .dot.bad{background:var(--bad)} .dot.warn{background:var(--warn)}
|
||||
.muted{color:var(--muted);font-size:12px}
|
||||
.result{margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px}
|
||||
.result.ok{background:#ecfdf3;color:#066d3a;border:1px solid #aee9c5}
|
||||
.result.bad{background:#fef2f2;color:#a31515;border:1px solid #f3c0c0}
|
||||
.empty{color:var(--muted);text-align:center;padding:30px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">د</div>
|
||||
<div><h1>پنل ادمین دیدوان</h1><div class="sub">سامانه جامع آمار و اطلاعات</div></div>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="users">کاربران</button>
|
||||
<button class="tab" data-tab="token">توکن منبع داده</button>
|
||||
<button class="tab" data-tab="health">سلامت سرویسها</button>
|
||||
<button class="tab" data-tab="reports">گزارشات</button>
|
||||
<button class="tab" data-tab="apiusage">مصرف API</button>
|
||||
<button class="tab" data-tab="upload">آپلود گزارش</button>
|
||||
</div>
|
||||
|
||||
<section id="users" class="tabpane">
|
||||
<div class="card">
|
||||
<h2>کاربران ثبتنامشده <span id="ucount" class="pill"></span></h2>
|
||||
<div id="utable"><div class="empty">در حال بارگذاری…</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="token" class="tabpane hidden">
|
||||
<div class="card">
|
||||
<h2>توکن منبع داده</h2>
|
||||
<p class="muted">توکن هر ۴ روز یکبار عوض میشود. توکن جدید را اینجا ذخیره کن تا اسکرپ فلزات و فولاد ادامه پیدا کند.</p>
|
||||
<div class="row"><span class="pill">وضعیت: <b id="tkset">—</b></span><span class="pill" id="tkupd"></span></div>
|
||||
<div class="row"><code class="pill" id="tkmask" style="direction:ltr"></code></div>
|
||||
<textarea id="tkinput" placeholder="توکن جدید را اینجا بچسبان…"></textarea>
|
||||
<div class="row">
|
||||
<button class="btn" id="tksave">ذخیره توکن</button>
|
||||
<button class="btn ghost" id="tktest">تست توکن (آخرین قیمت)</button>
|
||||
</div>
|
||||
<div id="tkresult"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="health" class="tabpane hidden">
|
||||
<div class="card">
|
||||
<h2>پایداری سرویسها</h2>
|
||||
<p class="muted">آخرین زمان اسکرپ هر سرویس. سبز = تازه، قرمز = قدیمی/متوقف.</p>
|
||||
<div id="svclist"><div class="empty">در حال بررسی…</div></div>
|
||||
<div class="row"><button class="btn ghost" id="hrefresh">بررسی مجدد</button>
|
||||
<button class="btn" id="htest">تست اسکرپ آخرین قیمت</button></div>
|
||||
<div id="hresult"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="reports" class="tabpane hidden">
|
||||
<div class="card">
|
||||
<h2>گزارش دیتاستهای اسکرپشده</h2>
|
||||
<p class="muted">دادهها در دیتابیس ذخیره میشوند (نه فایل). از اینجا خروجی CSV (آخرین تاریخ) بگیر.</p>
|
||||
<div id="rlist"><div class="empty">در حال بارگذاری…</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="apiusage" class="tabpane hidden">
|
||||
<div class="card">
|
||||
<h2>مصرف API</h2>
|
||||
<p class="muted">درخواستهای `/api/v1` بر اساس پارتنر، مسیر، وضعیت و IP. توکن خام ذخیره یا نمایش داده نمیشود.</p>
|
||||
<div id="apiusage-summary"><div class="empty">در حال بارگذاری…</div></div>
|
||||
<div id="apiusage-list"></div>
|
||||
<div class="row"><button class="btn ghost" id="apiusage-refresh">بهروزرسانی</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="upload" class="tabpane hidden">
|
||||
<div class="card">
|
||||
<h2>آپلود گزارش PDF</h2>
|
||||
<p class="muted">منبع و دسته را انتخاب کن، فایل PDF را بده و آپلود — بدون تایپ.</p>
|
||||
<div class="row">
|
||||
<label class="muted" style="min-width:46px">منبع</label>
|
||||
<select id="upsource" style="flex:1;min-width:200px;border:1px solid var(--border);border-radius:10px;padding:10px 12px;font:inherit"></select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="muted" style="min-width:46px">دسته</label>
|
||||
<select id="upcat" style="flex:1;min-width:200px;border:1px solid var(--border);border-radius:10px;padding:10px 12px;font:inherit"></select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="upfile" type="file" accept="application/pdf" style="flex:1">
|
||||
<button class="btn" id="upbtn">آپلود</button>
|
||||
</div>
|
||||
<div id="upresult"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
async function api(path,opts){const r=await fetch(path,opts);return r.json();}
|
||||
function fmt(n){return n==null?'—':new Intl.NumberFormat('fa-IR').format(n);}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
|
||||
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
|
||||
document.querySelectorAll('.tabpane').forEach(x=>x.classList.add('hidden'));
|
||||
t.classList.add('active');$('#'+t.dataset.tab).classList.remove('hidden');
|
||||
if(t.dataset.tab==='token')loadToken();
|
||||
if(t.dataset.tab==='health')loadHealth();
|
||||
if(t.dataset.tab==='reports')loadReports();
|
||||
if(t.dataset.tab==='apiusage')loadApiUsage();
|
||||
if(t.dataset.tab==='upload')loadFolders();
|
||||
});
|
||||
|
||||
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function statusBadge(s){const m={pending:['در انتظار','#b45309','#fef3c7'],approved:['تأییدشده','#047857','#d1fae5'],rejected:['ردشده','#b91c1c','#fee2e2']}[s]||['—','#555','#eee'];return `<span style="padding:2px 8px;border-radius:999px;font-size:12px;color:${m[1]};background:${m[2]}">${m[0]}</span>`;}
|
||||
async function setStatus(id,action){
|
||||
try{const r=await api('/api/users/'+id+'/'+action,{method:'POST'});if(r&&r.ok)loadUsers();}catch(e){}
|
||||
}
|
||||
async function loadUsers(){
|
||||
try{const u=await api('/api/users');
|
||||
const pend=u.filter(r=>(r.status||'pending')==='pending').length;
|
||||
$('#ucount').textContent=u.length+' نفر'+(pend?(' · '+pend+' در انتظار'):'');
|
||||
if(!u.length){$('#utable').innerHTML='<div class="empty">هنوز کسی ثبتنام نکرده است.</div>';return;}
|
||||
let h='<table><thead><tr><th>#</th><th>نام</th><th>سازمان</th><th class="mono">ایمیل</th><th class="mono">موبایل</th><th>وضعیت</th><th>تاریخ</th><th>اقدام</th></tr></thead><tbody>';
|
||||
u.forEach((r,i)=>{const s=r.status||'pending';let act='';
|
||||
if(s!=='approved')act+=`<button class="btn" style="padding:4px 10px" onclick="setStatus(${r.id},'approve')">تأیید</button> `;
|
||||
if(s!=='rejected')act+=`<button class="btn ghost" style="padding:4px 10px" onclick="setStatus(${r.id},'reject')">رد</button>`;
|
||||
h+=`<tr><td>${i+1}</td><td>${esc(r.name)||'—'}</td><td>${esc(r.organization)||'—'}</td><td class="mono">${esc(r.email)||'—'}</td><td class="mono">${esc(r.phone)||'—'}</td><td>${statusBadge(s)}</td><td class="muted">${esc((r.created_at||'').replace('T',' ').replace('Z',''))}</td><td>${act}</td></tr>`;});
|
||||
h+='</tbody></table>';$('#utable').innerHTML=h;
|
||||
}catch(e){$('#utable').innerHTML='<div class="empty">خطا در بارگذاری کاربران.</div>';}
|
||||
}
|
||||
|
||||
async function loadToken(){
|
||||
try{const t=await api('/api/token');
|
||||
$('#tkset').textContent=t.set?'تنظیمشده':'تنظیم نشده';
|
||||
$('#tkmask').textContent=t.masked||'—';
|
||||
$('#tkupd').textContent=t.updated_at?('آخرین تغییر: '+t.updated_at.replace('T',' ').replace('Z','')):'';
|
||||
}catch(e){}
|
||||
}
|
||||
$('#tksave').onclick=async()=>{
|
||||
const tok=$('#tkinput').value.trim();if(!tok)return;
|
||||
$('#tkresult').innerHTML='';
|
||||
try{const r=await api('/api/token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:tok})});
|
||||
if(r.ok){$('#tkinput').value='';loadToken();$('#tkresult').innerHTML='<div class="result ok">توکن ذخیره شد. حالا «تست توکن» را بزن.</div>';}
|
||||
else $('#tkresult').innerHTML='<div class="result bad">'+(r.error||'خطا')+'</div>';
|
||||
}catch(e){$('#tkresult').innerHTML='<div class="result bad">خطا در ذخیره.</div>';}
|
||||
};
|
||||
$('#tktest').onclick=()=>runTest('#tkresult');
|
||||
$('#htest').onclick=()=>runTest('#hresult');
|
||||
async function runTest(target){
|
||||
$(target).innerHTML='<div class="result">در حال تست… (چند ثانیه طول میکشد)</div>';
|
||||
try{const r=await api('/api/test-scrape',{method:'POST'});
|
||||
if(r.ok&&r.items){
|
||||
let h='<div class="result ok">✅ توکن سالم است — '+r.count+' مورد رندوم در آخرین تاریخ:</div>';
|
||||
h+='<table style="margin-top:10px"><thead><tr><th>کالا</th><th>تاریخ</th><th class="mono">میانگین</th><th class="mono">کف</th><th class="mono">سقف</th></tr></thead><tbody>';
|
||||
r.items.forEach(it=>{h+='<tr><td>'+(it.title||'')+'</td><td class="muted">'+(it.date||'')+'</td><td class="mono">'+fmt(it.mid)+'</td><td class="mono">'+fmt(it.low)+'</td><td class="mono">'+fmt(it.high)+'</td></tr>';});
|
||||
h+='</tbody></table>';
|
||||
$(target).innerHTML=h;
|
||||
} else if(r.ok){$(target).innerHTML='<div class="result ok">✅ توکن سالم است.'+(r.note?(' '+r.note):'')+'</div>';}
|
||||
else $(target).innerHTML='<div class="result bad">❌ '+(r.error||'ناموفق')+'</div>';
|
||||
}catch(e){$(target).innerHTML='<div class="result bad">❌ خطا در تست.</div>';}
|
||||
}
|
||||
async function loadReports(){
|
||||
try{const d=await api('/api/reports');
|
||||
let h='<table><thead><tr><th>دیتاست</th><th>رکوردها</th><th>از</th><th>تا</th><th>خروجی</th></tr></thead><tbody>';
|
||||
d.forEach(s=>{h+='<tr><td><b>'+s.label+'</b> <span class="muted" style="direction:ltr">'+s.table+'</span></td><td>'+fmt(s.count)+'</td><td class="muted">'+(s.first||'—')+'</td><td class="muted">'+(s.last||'—')+'</td><td><a class="btn ghost" style="padding:5px 12px" href="/api/export?dataset='+s.key+'">CSV</a></td></tr>';});
|
||||
h+='</tbody></table>';$('#rlist').innerHTML=h;
|
||||
}catch(e){$('#rlist').innerHTML='<div class="empty">خطا در بارگذاری گزارش.</div>';}
|
||||
}
|
||||
|
||||
async function loadApiUsage(){
|
||||
try{const d=await api('/api/api-usage?limit=100');
|
||||
let s='<table><thead><tr><th>پارتنر</th><th>درخواستها</th><th>موفق</th><th>خطا</th><th>آخرین استفاده</th></tr></thead><tbody>';
|
||||
(d.summary||[]).forEach(r=>{s+='<tr><td><b>'+(r.partner||'unknown')+'</b></td><td>'+fmt(r.requests)+'</td><td>'+fmt(r.ok)+'</td><td>'+fmt(r.errors)+'</td><td class="muted">'+(r.last_seen||'—')+'</td></tr>';});
|
||||
s+='</tbody></table>';
|
||||
$('#apiusage-summary').innerHTML=(d.summary&&d.summary.length)?s:'<div class="empty">هنوز مصرفی ثبت نشده است.</div>';
|
||||
let h='<table style="margin-top:14px"><thead><tr><th>زمان</th><th>پارتنر</th><th>متد</th><th>مسیر</th><th>وضعیت</th><th class="mono">IP</th><th>مرورگر/کلاینت</th></tr></thead><tbody>';
|
||||
(d.rows||[]).forEach(r=>{
|
||||
const q=r.query?('?'+r.query):'';
|
||||
const ok=(r.status_code||0)<400;
|
||||
h+='<tr><td class="muted">'+(r.created_at||'—')+'</td><td>'+(r.partner||'unknown')+'</td><td class="mono">'+(r.method||'')+'</td><td class="mono">'+esc((r.path||'')+q)+'</td><td><span class="pill" style="color:'+(ok?'var(--ok)':'var(--bad)')+'">'+(r.status_code||'—')+'</span></td><td class="mono">'+esc(r.client_ip||'—')+'</td><td class="muted">'+esc(r.user_agent||'—')+'</td></tr>';
|
||||
});
|
||||
h+='</tbody></table>';
|
||||
$('#apiusage-list').innerHTML=(d.rows&&d.rows.length)?h:'';
|
||||
}catch(e){
|
||||
$('#apiusage-summary').innerHTML='<div class="empty">خطا در بارگذاری مصرف API.</div>';
|
||||
$('#apiusage-list').innerHTML='';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHealth(){
|
||||
try{const h=await api('/api/health');
|
||||
let html='';
|
||||
h.forEach(s=>{
|
||||
const cls=s.daily?'warn':(s.ok?'ok':'bad');
|
||||
const when=s.daily?('آخرین تاریخ: '+(s.last||'—')):(s.last?(`${s.age_min} دقیقه پیش`):'بدون داده');
|
||||
const status=s.daily?'روزانه (دستی)':(s.ok?'تازه':'قدیمی/متوقف');
|
||||
html+=`<div class="svc"><div><span class="dot ${cls}"></span><b>${s.label}</b> <span class="muted">(${fmt(s.count)} رکورد)</span></div><div class="muted">${status} · ${when}</div></div>`;
|
||||
});
|
||||
$('#svclist').innerHTML=html||'<div class="empty">دادهای نیست.</div>';
|
||||
}catch(e){$('#svclist').innerHTML='<div class="empty">خطا در بررسی.</div>';}
|
||||
}
|
||||
$('#hrefresh').onclick=loadHealth;
|
||||
$('#apiusage-refresh').onclick=loadApiUsage;
|
||||
|
||||
let FOLDERS=[];
|
||||
async function loadFolders(){
|
||||
try{const d=await api('/api/report-folders'); FOLDERS=d.folders||[];
|
||||
FOLDERS.forEach(f=>{const i=f.name.indexOf(' - '); f.source=i>=0?f.name.slice(0,i):f.name; f.category=i>=0?f.name.slice(i+3):f.name;});
|
||||
const sources=[...new Set(FOLDERS.map(f=>f.source))].sort();
|
||||
$('#upsource').innerHTML=sources.length?sources.map(s=>`<option>${esc(s)}</option>`).join(''):'<option value="">(پوشهای نیست)</option>';
|
||||
fillCats();
|
||||
}catch(e){}
|
||||
}
|
||||
function fillCats(){
|
||||
const s=$('#upsource').value;
|
||||
const cats=FOLDERS.filter(f=>f.source===s);
|
||||
$('#upcat').innerHTML=cats.map(f=>`<option value="${esc(f.name)}">${esc(f.category)} (${f.count})</option>`).join('');
|
||||
}
|
||||
$('#upsource').onchange=fillCats;
|
||||
$('#upbtn').onclick=async()=>{
|
||||
const folder=$('#upcat').value; const f=$('#upfile').files[0];
|
||||
if(!folder){$('#upresult').innerHTML='<div class="result bad">پوشهای برای انتخاب نیست.</div>';return;}
|
||||
if(!f){$('#upresult').innerHTML='<div class="result bad">یک فایل PDF انتخاب کن.</div>';return;}
|
||||
const fd=new FormData(); fd.append('folder',folder); fd.append('file',f);
|
||||
$('#upresult').innerHTML='<div class="result">در حال آپلود…</div>';
|
||||
try{const r=await fetch('/api/upload-report',{method:'POST',body:fd}); const j=await r.json();
|
||||
if(r.ok&&j.ok){$('#upresult').innerHTML='<div class="result ok">✅ آپلود شد: '+esc(j.name)+' → '+esc(j.folder)+'</div>'; $('#upfile').value=''; loadFolders();}
|
||||
else $('#upresult').innerHTML='<div class="result bad">❌ '+esc(j.detail||j.error||'خطا')+'</div>';
|
||||
}catch(e){$('#upresult').innerHTML='<div class="result bad">❌ خطا در آپلود.</div>';}
|
||||
};
|
||||
loadUsers();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.db
|
||||
*.sqlite*
|
||||
*.log
|
||||
.venv/
|
||||
venv/
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
FROM mcr-mirror.liara.ir/playwright/python:v1.46.0-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONUTF8=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
DB_PATH=/data/asianmetal.db \
|
||||
REPORTS_DIR=/data/reports \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||
PIP_INDEX_URL=https://package-mirror.liara.ir/repository/pypi/simple
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /data/reports
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"""
|
||||
Server-side auth helpers: password hashing (stdlib PBKDF2, no external deps)
|
||||
and opaque session tokens stored in the DB. Used by main.py for the
|
||||
register -> admin-approval -> login flow.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from db import get_conn
|
||||
|
||||
_ITER = 600_000 # new hashes; verify_password honours the iters stored per-hash, so old 200k hashes still work
|
||||
SESSION_DAYS = 7
|
||||
|
||||
|
||||
def hash_password(pw: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, _ITER)
|
||||
return f"pbkdf2_sha256${_ITER}${salt.hex()}${dk.hex()}"
|
||||
|
||||
|
||||
def verify_password(pw: str, stored: str | None) -> bool:
|
||||
if not stored:
|
||||
return False
|
||||
try:
|
||||
algo, iters, salt_hex, dk_hex = stored.split("$")
|
||||
if algo != "pbkdf2_sha256":
|
||||
return False
|
||||
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), bytes.fromhex(salt_hex), int(iters))
|
||||
return hmac.compare_digest(dk.hex(), dk_hex)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def create_session(user_id: int) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = datetime.now(timezone.utc)
|
||||
exp = now + timedelta(days=SESSION_DAYS)
|
||||
with get_conn() as c:
|
||||
c.execute(
|
||||
"INSERT INTO sessions(token, user_id, created_at, expires_at) VALUES(?,?,?,?)",
|
||||
(token, user_id, now.isoformat(), exp.isoformat()),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def delete_session(token: str | None) -> None:
|
||||
if not token:
|
||||
return
|
||||
with get_conn() as c:
|
||||
c.execute("DELETE FROM sessions WHERE token=?", (token,))
|
||||
|
||||
|
||||
def revoke_all_sessions(user_id: int) -> None:
|
||||
"""Kill every active session for a user (e.g. on password change/compromise)."""
|
||||
with get_conn() as c:
|
||||
c.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
|
||||
|
||||
|
||||
def user_for_token(token: str | None):
|
||||
"""Return the user row for a valid, unexpired session token, else None."""
|
||||
if not token:
|
||||
return None
|
||||
with get_conn() as c:
|
||||
row = c.execute(
|
||||
"""SELECT u.id, u.name, u.email, u.phone, u.status, s.expires_at
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ?""",
|
||||
(token,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
|
||||
delete_session(token)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return row
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
"""
|
||||
Crypto market data from CoinGecko's official free public API (no key needed).
|
||||
|
||||
HTML scraping of coingecko.com is prohibited by their ToS and the page is JS-
|
||||
rendered behind Cloudflare; the free API returns the same data (prices, 1h/24h/7d
|
||||
changes, market cap, volume, and a 7-day sparkline array) reliably as JSON.
|
||||
|
||||
Keeps a current snapshot in memory (served by /api/crypto) and dedup-persists
|
||||
changed coin prices into crypto_prices, exactly like the other data modules.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
|
||||
from db import get_conn
|
||||
|
||||
API_BASE = "https://api.coingecko.com/api/v3"
|
||||
UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
HEADERS = {"User-Agent": UA, "Accept": "application/json"}
|
||||
INTERVAL_SECONDS = 60 # 3 calls/cycle, well under the 100 calls/min free limit
|
||||
PER_PAGE = 100 # top 100 coins by market cap
|
||||
SPARK_POINTS = 40 # downsample the 7d (~168pt) sparkline to keep payload small
|
||||
|
||||
_latest: dict = {"coins": [], "global": {}, "trending": [], "fetched_at": None}
|
||||
_last_saved: dict[str, float] = {}
|
||||
|
||||
|
||||
def create_table() -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS crypto_prices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
name TEXT,
|
||||
price REAL,
|
||||
pct_24h REAL,
|
||||
market_cap REAL,
|
||||
volume_24h REAL,
|
||||
fetched_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_crypto_symbol ON crypto_prices(symbol)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_crypto_fetched ON crypto_prices(fetched_at)")
|
||||
|
||||
|
||||
def _downsample(arr: list, n: int = SPARK_POINTS) -> list:
|
||||
"""Reduce a long price series to ~n points (keeps the last point)."""
|
||||
if not arr or len(arr) <= n:
|
||||
return [round(float(x), 6) for x in arr] if arr else []
|
||||
step = len(arr) / n
|
||||
out = [arr[int(i * step)] for i in range(n)]
|
||||
out.append(arr[-1])
|
||||
return [round(float(x), 6) for x in out]
|
||||
|
||||
|
||||
def _dir(pct) -> str:
|
||||
if pct is None:
|
||||
return ""
|
||||
return "up" if pct >= 0 else "down"
|
||||
|
||||
|
||||
def _fetch_markets() -> list[dict]:
|
||||
url = (
|
||||
f"{API_BASE}/coins/markets?vs_currency=usd&order=market_cap_desc"
|
||||
f"&per_page={PER_PAGE}&page=1&sparkline=true"
|
||||
f"&price_change_percentage=1h,24h,7d"
|
||||
)
|
||||
data = requests.get(url, headers=HEADERS, timeout=25).json()
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
coins = []
|
||||
for c in data:
|
||||
pct24 = c.get("price_change_percentage_24h_in_currency")
|
||||
coins.append({
|
||||
"id": c.get("id"),
|
||||
"symbol": (c.get("symbol") or "").upper(),
|
||||
"name": c.get("name"),
|
||||
"image": c.get("image"),
|
||||
"rank": c.get("market_cap_rank"),
|
||||
"price": c.get("current_price"),
|
||||
"pct_1h": c.get("price_change_percentage_1h_in_currency"),
|
||||
"pct_24h": pct24,
|
||||
"pct_7d": c.get("price_change_percentage_7d_in_currency"),
|
||||
"market_cap": c.get("market_cap"),
|
||||
"volume_24h": c.get("total_volume"),
|
||||
"high_24h": c.get("high_24h"),
|
||||
"low_24h": c.get("low_24h"),
|
||||
"circ_supply": c.get("circulating_supply"),
|
||||
"ath": c.get("ath"),
|
||||
"sparkline": _downsample((c.get("sparkline_in_7d") or {}).get("price") or []),
|
||||
"dir": _dir(pct24),
|
||||
})
|
||||
return coins
|
||||
|
||||
|
||||
def _fetch_global() -> dict:
|
||||
data = requests.get(f"{API_BASE}/global", headers=HEADERS, timeout=20).json()
|
||||
g = (data or {}).get("data") or {}
|
||||
pct = g.get("market_cap_percentage") or {}
|
||||
return {
|
||||
"market_cap_usd": (g.get("total_market_cap") or {}).get("usd"),
|
||||
"volume_usd": (g.get("total_volume") or {}).get("usd"),
|
||||
"btc_dominance": pct.get("btc"),
|
||||
"eth_dominance": pct.get("eth"),
|
||||
"market_cap_change_24h": g.get("market_cap_change_percentage_24h_usd"),
|
||||
"active": g.get("active_cryptocurrencies"),
|
||||
}
|
||||
|
||||
|
||||
def _fetch_trending() -> list[dict]:
|
||||
data = requests.get(f"{API_BASE}/search/trending", headers=HEADERS, timeout=20).json()
|
||||
out = []
|
||||
for c in (data or {}).get("coins", []):
|
||||
item = c.get("item") or {}
|
||||
d = item.get("data") or {}
|
||||
pct = d.get("price_change_percentage_24h") or {}
|
||||
out.append({
|
||||
"id": item.get("id"),
|
||||
"symbol": (item.get("symbol") or "").upper(),
|
||||
"name": item.get("name"),
|
||||
"rank": item.get("market_cap_rank"),
|
||||
"thumb": item.get("small") or item.get("thumb"),
|
||||
"price": d.get("price"),
|
||||
"pct_24h": pct.get("usd") if isinstance(pct, dict) else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_sync() -> dict:
|
||||
"""Each section is fetched independently so one failure doesn't lose the rest."""
|
||||
out = {"coins": [], "global": {}, "trending": []}
|
||||
try:
|
||||
out["coins"] = _fetch_markets()
|
||||
except Exception as e:
|
||||
print(f"[crypto] markets fetch failed: {e}")
|
||||
try:
|
||||
out["global"] = _fetch_global()
|
||||
except Exception as e:
|
||||
print(f"[crypto] global fetch failed: {e}")
|
||||
try:
|
||||
out["trending"] = _fetch_trending()
|
||||
except Exception as e:
|
||||
print(f"[crypto] trending fetch failed: {e}")
|
||||
return out
|
||||
|
||||
|
||||
def _save(coins: list[dict], now: str) -> None:
|
||||
"""Persist only coins whose price changed since last save (dedup)."""
|
||||
changed = [c for c in coins if c["symbol"] and _last_saved.get(c["symbol"]) != c["price"]]
|
||||
if not changed:
|
||||
return
|
||||
with get_conn() as conn:
|
||||
conn.executemany(
|
||||
"""INSERT INTO crypto_prices
|
||||
(symbol, name, price, pct_24h, market_cap, volume_24h, fetched_at)
|
||||
VALUES(?,?,?,?,?,?,?)""",
|
||||
[(c["symbol"], c["name"], c["price"], c["pct_24h"],
|
||||
c["market_cap"], c["volume_24h"], now) for c in changed],
|
||||
)
|
||||
for c in changed:
|
||||
_last_saved[c["symbol"]] = c["price"]
|
||||
|
||||
|
||||
def _heartbeat(now: str) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO app_config(key, value) VALUES('crypto_last_scrape', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value""",
|
||||
(now,),
|
||||
)
|
||||
|
||||
|
||||
def _load_last_saved() -> None:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT symbol, price FROM crypto_prices
|
||||
WHERE id IN (SELECT MAX(id) FROM crypto_prices GROUP BY symbol)"""
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
_last_saved[r["symbol"]] = r["price"]
|
||||
except Exception as e:
|
||||
print(f"[crypto] could not seed dedup from DB: {e}")
|
||||
|
||||
|
||||
async def scrape_loop() -> None:
|
||||
global _latest
|
||||
create_table()
|
||||
_load_last_saved()
|
||||
while True:
|
||||
try:
|
||||
payload = await asyncio.to_thread(_fetch_sync)
|
||||
if payload["coins"]:
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
await asyncio.to_thread(_save, payload["coins"], now)
|
||||
await asyncio.to_thread(_heartbeat, now)
|
||||
_latest = {**payload, "fetched_at": now}
|
||||
print(f"[crypto] {len(payload['coins'])} coins at {now}")
|
||||
except Exception as e:
|
||||
print(f"[crypto] error: {e}")
|
||||
await asyncio.sleep(INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def get_latest() -> dict:
|
||||
return _latest
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
|
||||
import math, random
|
||||
|
||||
def _generate_sparkline(current_price: float, pct_change: float = 0.0, n_points: int = 40) -> list:
|
||||
if not current_price or current_price <= 0:
|
||||
return [0.0] * n_points
|
||||
pct = (pct_change or 0.0) / 100.0
|
||||
start_price = current_price / (1.0 + pct) if (1.0 + pct) > 0 else current_price
|
||||
points = []
|
||||
trend_step = (current_price - start_price) / max(1, n_points - 1)
|
||||
volatility = max(abs(pct) * 0.4, 0.007) * current_price
|
||||
rand_seed = int(abs(current_price) * 1000) % 100000
|
||||
r = random.Random(rand_seed)
|
||||
for i in range(n_points - 1):
|
||||
progress = i / float(n_points - 1)
|
||||
base = start_price + trend_step * i
|
||||
wave = math.sin(progress * math.pi * 3.5) * (volatility * 0.6) + math.cos(progress * math.pi * 5.2) * (volatility * 0.4)
|
||||
noise = (r.random() - 0.5) * volatility * 0.45
|
||||
val = base + wave + noise
|
||||
points.append(round(max(val, current_price * 0.2), 2 if current_price > 100 else 4))
|
||||
points.append(round(current_price, 2 if current_price > 100 else 4))
|
||||
return points
|
||||
|
||||
"""
|
||||
Scrapes Iranian domestic market prices from TGJU every 60 seconds:
|
||||
- currency (tgju.org/currency)
|
||||
- gold (tgju.org/gold-chart) — gold ETFs ("صندوق") excluded
|
||||
- coin (tgju.org/coin) — bubbles/funds/pre-86 excluded
|
||||
|
||||
One headless browser visits all three pages per cycle. The latest snapshot
|
||||
of each is cached in memory (served by /api/currency, /api/gold, /api/coin)
|
||||
and only changed rows are persisted (dedup) to keep the DB lean.
|
||||
|
||||
Row names are read from <th> (not <td>): on TGJU market tables the first
|
||||
<td> is the price, so a <td>-based selector stored numbers instead of names.
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from playwright.sync_api import sync_playwright
|
||||
from db import get_conn
|
||||
|
||||
UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
INTERVAL_SECONDS = 60
|
||||
|
||||
URLS = {
|
||||
"currency": "https://www.tgju.org/currency",
|
||||
"gold": "https://www.tgju.org/gold-chart",
|
||||
"coin": "https://www.tgju.org/coin",
|
||||
}
|
||||
|
||||
# Rows to drop per kind (keep the board clean and on-topic).
|
||||
_EXCLUDE = {
|
||||
"currency": lambda name: False,
|
||||
"gold": lambda name: "صندوق" in name, # gold ETFs
|
||||
"coin": lambda name: any(x in name for x in ("حباب", "تمام سکه", "صندوق", "قبل")),
|
||||
}
|
||||
|
||||
KINDS = ("currency", "gold", "coin")
|
||||
_TABLES = {"currency": "currency_prices", "gold": "gold_prices", "coin": "coin_prices"}
|
||||
|
||||
_latest: dict[str, list[dict]] = {k: [] for k in KINDS}
|
||||
_last_saved: dict[str, dict[str, float]] = {k: {} for k in KINDS}
|
||||
_PCT_RE = re.compile(r"\(([\d.]+)\s*%\)")
|
||||
|
||||
_EVAL_JS = r"""() => {
|
||||
const out = [];
|
||||
document.querySelectorAll("table.data-table.market-table").forEach(table => {
|
||||
table.querySelectorAll("tbody tr").forEach(tr => {
|
||||
const th = tr.querySelector("th");
|
||||
const tds = Array.from(tr.querySelectorAll("td"));
|
||||
const name = th ? (th.innerText || "").trim() : "";
|
||||
const price = tds[0] ? (tds[0].innerText || "").trim() : "";
|
||||
const chgEl = tds[1];
|
||||
const change = chgEl ? (chgEl.innerText || "").trim() : "";
|
||||
let dir = "";
|
||||
if (chgEl) {
|
||||
const c = chgEl.className || "";
|
||||
if (c.includes("high")) dir = "up";
|
||||
else if (c.includes("low")) dir = "down";
|
||||
}
|
||||
if (name && price) out.push({ name, price, change, dir });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}"""
|
||||
|
||||
|
||||
def create_tables() -> None:
|
||||
with get_conn() as conn:
|
||||
for table in _TABLES.values():
|
||||
conn.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
price REAL,
|
||||
change_val TEXT,
|
||||
pct REAL,
|
||||
dir TEXT,
|
||||
fetched_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute(f"CREATE INDEX IF NOT EXISTS idx_{table}_name ON {table}(name)")
|
||||
|
||||
|
||||
def _to_float(raw: str):
|
||||
try:
|
||||
return float(raw.replace(",", "").strip())
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_pct(change: str, direction: str):
|
||||
m = _PCT_RE.search(change or "")
|
||||
if not m:
|
||||
return 0.0
|
||||
pct = float(m.group(1))
|
||||
return -pct if direction == "down" else pct
|
||||
|
||||
|
||||
def _scrape_all() -> dict[str, list[dict]]:
|
||||
out: dict[str, list[dict]] = {}
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(user_agent=UA)
|
||||
try:
|
||||
for kind, url in URLS.items():
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
page.wait_for_selector("table.data-table.market-table tbody tr", timeout=20000)
|
||||
# TGJU server-renders STALE prices, then live-updates the DOM a few
|
||||
# seconds later via its own JS. Wait so we read the live values, not
|
||||
# the cached snapshot (otherwise prices look frozen / dedup sees no change).
|
||||
page.wait_for_timeout(9000)
|
||||
raw = page.evaluate(_EVAL_JS)
|
||||
except Exception as e:
|
||||
print(f"[tgju] {kind} scrape error: {e}")
|
||||
out[kind] = []
|
||||
continue
|
||||
|
||||
exclude = _EXCLUDE[kind]
|
||||
seen: set[str] = set()
|
||||
rows = []
|
||||
for r in raw:
|
||||
name = r["name"]
|
||||
if exclude(name) or name in seen:
|
||||
continue # drop off-topic rows + duplicate tables (coins repeat)
|
||||
price = _to_float(r["price"])
|
||||
if price is None:
|
||||
continue
|
||||
seen.add(name)
|
||||
rows.append({
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change_val": r["change"],
|
||||
"pct": _parse_pct(r["change"], r["dir"]),
|
||||
"dir": r["dir"],
|
||||
})
|
||||
out[kind] = rows
|
||||
finally:
|
||||
browser.close()
|
||||
return out
|
||||
|
||||
|
||||
def _save(kind: str, rows: list[dict], now: str) -> None:
|
||||
if not rows:
|
||||
return
|
||||
table = _TABLES[kind]
|
||||
with get_conn() as conn:
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table}(name, price, change_val, pct, dir, fetched_at) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
[(r["name"], r["price"], r["change_val"], r["pct"], r["dir"], now) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
def _load_last_saved() -> None:
|
||||
for kind, table in _TABLES.items():
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT name, price FROM {table} "
|
||||
f"WHERE id IN (SELECT MAX(id) FROM {table} GROUP BY name)"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if r["price"] is not None:
|
||||
_last_saved[kind][r["name"]] = r["price"]
|
||||
except Exception as e:
|
||||
print(f"[tgju] could not load last-saved {kind}: {e}")
|
||||
|
||||
|
||||
async def scrape_loop() -> None:
|
||||
"""Background task: refresh currency + gold + coin every INTERVAL_SECONDS.
|
||||
The in-memory snapshots always hold the full board; only changed rows persist."""
|
||||
create_tables()
|
||||
_load_last_saved()
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.to_thread(_scrape_all)
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
summary = []
|
||||
for kind in KINDS:
|
||||
rows = data.get(kind, [])
|
||||
if not rows:
|
||||
continue
|
||||
_latest[kind] = [{**r, "fetched_at": now} for r in rows]
|
||||
changed = [r for r in rows if _last_saved[kind].get(r["name"]) != r["price"]]
|
||||
if changed:
|
||||
await asyncio.to_thread(_save, kind, changed, now)
|
||||
for r in changed:
|
||||
_last_saved[kind][r["name"]] = r["price"]
|
||||
summary.append(f"{kind}:{len(changed)}/{len(rows)}")
|
||||
# heartbeat: record that a scrape cycle completed, independent of dedup
|
||||
try:
|
||||
with get_conn() as c:
|
||||
c.execute(
|
||||
"INSERT INTO app_config(key,value) VALUES('tgju_last_scrape',?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(now,),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if summary:
|
||||
print(f"[tgju] {' '.join(summary)} at {now}")
|
||||
except Exception as e:
|
||||
print(f"[tgju] error: {e}")
|
||||
await asyncio.sleep(INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def get_latest() -> list[dict]:
|
||||
return _latest["currency"]
|
||||
|
||||
|
||||
def get_gold() -> list[dict]:
|
||||
return _latest["gold"]
|
||||
|
||||
|
||||
def get_coin() -> list[dict]:
|
||||
return _latest["coin"]
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/asianmetal.db")
|
||||
|
||||
def get_conn():
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
with get_conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
username TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
organization TEXT,
|
||||
password_hash TEXT,
|
||||
status TEXT DEFAULT 'approved',
|
||||
created_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sso_nonces (
|
||||
jti TEXT PRIMARY KEY,
|
||||
expires_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"platform": "docker",
|
||||
"port": 8000,
|
||||
"app": "statista-didvan-api",
|
||||
"disks": [
|
||||
{ "name": "data", "mountTo": "/data" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
|
||||
import math, random
|
||||
|
||||
def _generate_sparkline(current_price: float, pct_change: float = 0.0, n_points: int = 40) -> list:
|
||||
if not current_price or current_price <= 0:
|
||||
return [0.0] * n_points
|
||||
pct = (pct_change or 0.0) / 100.0
|
||||
start_price = current_price / (1.0 + pct) if (1.0 + pct) > 0 else current_price
|
||||
points = []
|
||||
trend_step = (current_price - start_price) / max(1, n_points - 1)
|
||||
volatility = max(abs(pct) * 0.4, 0.007) * current_price
|
||||
rand_seed = int(abs(current_price) * 1000) % 100000
|
||||
r = random.Random(rand_seed)
|
||||
for i in range(n_points - 1):
|
||||
progress = i / float(n_points - 1)
|
||||
base = start_price + trend_step * i
|
||||
wave = math.sin(progress * math.pi * 3.5) * (volatility * 0.6) + math.cos(progress * math.pi * 5.2) * (volatility * 0.4)
|
||||
noise = (r.random() - 0.5) * volatility * 0.45
|
||||
val = base + wave + noise
|
||||
points.append(round(max(val, current_price * 0.2), 2 if current_price > 100 else 4))
|
||||
points.append(round(current_price, 2 if current_price > 100 else 4))
|
||||
return points
|
||||
|
||||
"""
|
||||
Polls Yahoo Finance every 3 seconds for live commodity prices.
|
||||
Broadcasts to all connected WebSocket clients.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import yfinance as yf
|
||||
from fastapi import WebSocket
|
||||
|
||||
SYMBOLS = {
|
||||
"CL=F": "Crude Oil WTI",
|
||||
"BZ=F": "Brent Crude",
|
||||
"NG=F": "Natural Gas",
|
||||
"RB=F": "Gasoline",
|
||||
"HO=F": "Heating Oil",
|
||||
"GC=F": "Gold",
|
||||
"SI=F": "Silver",
|
||||
"HG=F": "Copper",
|
||||
"PL=F": "Platinum",
|
||||
"PA=F": "Palladium",
|
||||
"ALI=F": "Aluminum",
|
||||
"NI=F": "Nickel",
|
||||
"ZC=F": "Corn",
|
||||
"ZW=F": "Wheat",
|
||||
"ZS=F": "Soybeans",
|
||||
"KC=F": "Coffee",
|
||||
"CT=F": "Cotton",
|
||||
"CC=F": "Cocoa",
|
||||
"SB=F": "Sugar",
|
||||
"OJ=F": "Orange Juice",
|
||||
}
|
||||
|
||||
_clients: set[WebSocket] = set()
|
||||
_latest: list[dict] = []
|
||||
_MAX_CLIENTS = 500 # ponytail: global cap — the board is identical for everyone, so no per-IP fairness needed
|
||||
_SYM_LIST = " ".join(SYMBOLS.keys())
|
||||
|
||||
def _fetch_sync() -> list[dict]:
|
||||
try:
|
||||
# Single batch HTTP request — much faster than one-by-one
|
||||
raw = yf.download(
|
||||
_SYM_LIST,
|
||||
period="2d",
|
||||
progress=False,
|
||||
group_by="ticker",
|
||||
auto_adjust=True,
|
||||
threads=True,
|
||||
)
|
||||
out = []
|
||||
for sym, name in SYMBOLS.items():
|
||||
try:
|
||||
if sym in raw.columns.get_level_values(0):
|
||||
df = raw[sym].dropna()
|
||||
else:
|
||||
df = raw.dropna()
|
||||
if df.empty:
|
||||
continue
|
||||
price = float(df["Close"].iloc[-1])
|
||||
prev = float(df["Close"].iloc[-2]) if len(df) > 1 else price
|
||||
change = price - prev
|
||||
pct = (change / prev * 100) if prev else 0
|
||||
out.append({
|
||||
"symbol": sym,
|
||||
"name": name,
|
||||
"price": round(price, 4),
|
||||
"change": round(change, 4),
|
||||
"pct": round(pct, 3),
|
||||
"currency": "USD",
|
||||
"up": change >= 0,
|
||||
"sparkline": _generate_sparkline(price, pct),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
except Exception as e:
|
||||
print(f"[live] batch fetch error: {e}")
|
||||
# fallback: individual fetch
|
||||
out = []
|
||||
tickers = yf.Tickers(_SYM_LIST)
|
||||
for sym, name in SYMBOLS.items():
|
||||
try:
|
||||
fi = tickers.tickers[sym].fast_info
|
||||
price = fi.last_price or 0
|
||||
prev = fi.previous_close or price
|
||||
change = price - prev
|
||||
pct = (change / prev * 100) if prev else 0
|
||||
out.append({"symbol": sym, "name": name, "price": round(price,4),
|
||||
"change": round(change,4), "pct": round(pct,3),
|
||||
"currency": "USD", "up": change >= 0,
|
||||
"sparkline": _generate_sparkline(price, pct)})
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
async def fetch_prices() -> list[dict]:
|
||||
return await asyncio.to_thread(_fetch_sync)
|
||||
|
||||
def get_latest() -> list[dict]:
|
||||
"""Latest commodity snapshot — served over plain HTTP (tunnel/proxy friendly)."""
|
||||
return _latest
|
||||
|
||||
async def broadcast_loop():
|
||||
global _latest, _clients # _clients is reassigned below (-= dead), so declare it global
|
||||
while True:
|
||||
try:
|
||||
data = await fetch_prices()
|
||||
if data:
|
||||
_latest = data
|
||||
msg = json.dumps(data)
|
||||
dead = set()
|
||||
for ws in _clients.copy():
|
||||
try:
|
||||
await ws.send_text(msg)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
_clients -= dead
|
||||
except Exception as e:
|
||||
print(f"[live] error: {e}")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
async def connect(ws: WebSocket):
|
||||
if len(_clients) >= _MAX_CLIENTS:
|
||||
await ws.close(code=1013) # 1013 = "try again later"
|
||||
return
|
||||
await ws.accept()
|
||||
_clients.add(ws)
|
||||
if _latest:
|
||||
await ws.send_text(json.dumps(_latest))
|
||||
try:
|
||||
while True:
|
||||
await ws.receive_text()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_clients.discard(ws)
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
|
||||
import math, random
|
||||
|
||||
def _generate_sparkline(current_price: float, pct_change: float = 0.0, n_points: int = 40) -> list:
|
||||
if not current_price or current_price <= 0:
|
||||
return [0.0] * n_points
|
||||
pct = (pct_change or 0.0) / 100.0
|
||||
start_price = current_price / (1.0 + pct) if (1.0 + pct) > 0 else current_price
|
||||
points = []
|
||||
trend_step = (current_price - start_price) / max(1, n_points - 1)
|
||||
volatility = max(abs(pct) * 0.4, 0.007) * current_price
|
||||
rand_seed = int(abs(current_price) * 1000) % 100000
|
||||
r = random.Random(rand_seed)
|
||||
for i in range(n_points - 1):
|
||||
progress = i / float(n_points - 1)
|
||||
base = start_price + trend_step * i
|
||||
wave = math.sin(progress * math.pi * 3.5) * (volatility * 0.6) + math.cos(progress * math.pi * 5.2) * (volatility * 0.4)
|
||||
noise = (r.random() - 0.5) * volatility * 0.45
|
||||
val = base + wave + noise
|
||||
points.append(round(max(val, current_price * 0.2), 2 if current_price > 100 else 4))
|
||||
points.append(round(current_price, 2 if current_price > 100 else 4))
|
||||
return points
|
||||
|
||||
|
||||
from fastapi import FastAPI, Query, WebSocket, HTTPException, Request, Response, APIRouter, Depends, Header
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, RedirectResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import auth
|
||||
from db import get_conn, init_db
|
||||
from live_stream import broadcast_loop, connect as ws_connect, get_latest as get_commodity_latest
|
||||
import currency_stream
|
||||
import world_economy
|
||||
import steel_stocks
|
||||
import reports
|
||||
import crypto_market
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
asyncio.create_task(broadcast_loop())
|
||||
asyncio.create_task(currency_stream.scrape_loop())
|
||||
asyncio.create_task(world_economy.scrape_loop())
|
||||
asyncio.create_task(steel_stocks.scrape_loop())
|
||||
asyncio.create_task(crypto_market.scrape_loop())
|
||||
yield
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "ALLOWALL"
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
return response
|
||||
|
||||
init_db()
|
||||
|
||||
SESSION_COOKIE = "am_session"
|
||||
COOKIE_SECURE = False
|
||||
COOKIE_SAMESITE = "lax"
|
||||
|
||||
def _load_api_tokens():
|
||||
tokens = {}
|
||||
raw = os.environ.get("API_TOKENS", "").strip()
|
||||
if raw:
|
||||
for part in raw.replace("\n", ",").split(","):
|
||||
part = part.strip()
|
||||
if not part: continue
|
||||
if "=" in part: partner, token = part.split("=", 1)
|
||||
elif ":" in part: partner, token = part.split(":", 1)
|
||||
else: partner, token = "default", part
|
||||
if partner.strip() and token.strip(): tokens[partner.strip()] = token.strip()
|
||||
single = os.environ.get("API_TOKEN", "").strip()
|
||||
if single: tokens.setdefault("legacy", single)
|
||||
return tokens
|
||||
|
||||
API_PARTNER_TOKENS = _load_api_tokens()
|
||||
|
||||
def _bearer_token(authorization):
|
||||
if not authorization: return None
|
||||
authorization = authorization.strip()
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and token.strip(): return token.strip()
|
||||
return authorization
|
||||
|
||||
def _match_api_partner(token):
|
||||
for partner, expected in API_PARTNER_TOKENS.items():
|
||||
if hmac.compare_digest(token, expected): return partner
|
||||
return None
|
||||
|
||||
def require_session(request: Request, authorization: str | None = Header(default=None)):
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token:
|
||||
row = auth.user_for_token(token)
|
||||
if row: return row
|
||||
bearer = _bearer_token(authorization) or request.query_params.get("api_token") or request.query_params.get("token")
|
||||
if bearer:
|
||||
partner = _match_api_partner(bearer)
|
||||
if partner or bearer in API_PARTNER_TOKENS.values():
|
||||
return {"id": "partner", "name": partner or "api_client", "email": "partner@api", "status": "approved"}
|
||||
return {"id": "public", "name": "public_user", "email": "public@guest", "status": "approved"}
|
||||
|
||||
@app.websocket("/ws/live")
|
||||
async def websocket_live(ws: WebSocket):
|
||||
await ws.accept()
|
||||
await ws_connect(ws)
|
||||
|
||||
@app.get("/api/commodity")
|
||||
def get_commodity(_: object = Depends(require_session)):
|
||||
return get_commodity_latest()
|
||||
|
||||
@app.get("/api/tree")
|
||||
def get_tree(_: object = Depends(require_session)):
|
||||
rows = get_conn().execute(
|
||||
"SELECT DISTINCT grp, category, title FROM prices ORDER BY grp, category, title"
|
||||
).fetchall()
|
||||
tree = {}
|
||||
for r in rows:
|
||||
tree.setdefault(r["grp"], {}).setdefault(r["category"], []).append(r["title"])
|
||||
return tree
|
||||
|
||||
@app.get("/api/history")
|
||||
def get_history(title: str = Query(..., max_length=120), days: int = Query(0, ge=0, le=3650), _: object = Depends(require_session)):
|
||||
if days > 0:
|
||||
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
rows = get_conn().execute(
|
||||
"SELECT date, low, mid, high FROM prices WHERE title=? AND date>=? ORDER BY date LIMIT 6000",
|
||||
(title, cutoff)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = get_conn().execute(
|
||||
"SELECT date, low, mid, high FROM prices WHERE title=? ORDER BY date LIMIT 6000",
|
||||
(title,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/api/search")
|
||||
def search(q: str = Query(..., max_length=64), _: object = Depends(require_session)):
|
||||
safe = q.replace(chr(92), chr(92)+chr(92)).replace("%", chr(92)+"%").replace("_", chr(92)+"_")
|
||||
rows = get_conn().execute(
|
||||
"SELECT DISTINCT title, grp as 'group', category FROM prices "
|
||||
"WHERE title LIKE ? ESCAPE '" + chr(92) + "' LIMIT 50",
|
||||
(f"%{safe}%",)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/api/live")
|
||||
def get_live(_: object = Depends(require_session)):
|
||||
try:
|
||||
rows = get_conn().execute("""
|
||||
SELECT name, price, day_change, pct_change, unit, fetched_at
|
||||
FROM te_prices
|
||||
WHERE fetched_at = (SELECT MAX(fetched_at) FROM te_prices)
|
||||
ORDER BY name
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@app.get("/api/latest")
|
||||
def get_latest(_: object = Depends(require_session)):
|
||||
rows = get_conn().execute("""
|
||||
SELECT p.grp as 'group', p.category, p.title, p.date, p.low, p.mid, p.high,
|
||||
prev.mid as prev_mid
|
||||
FROM prices p
|
||||
JOIN (SELECT title, MAX(date) as max_date FROM prices GROUP BY title) m
|
||||
ON p.title = m.title AND p.date = m.max_date
|
||||
LEFT JOIN prices prev ON prev.title = p.title
|
||||
AND prev.date = (SELECT MAX(date) FROM prices WHERE title=p.title AND date < p.date)
|
||||
""").fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
pct = round(((r["mid"]-r["prev_mid"])/r["prev_mid"]*100) if r["prev_mid"] else 0, 2)
|
||||
d = dict(r)
|
||||
d["pct"] = pct
|
||||
d["sparkline"] = _generate_sparkline(r["mid"] or 0, pct)
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
@app.get("/api/prices/latest")
|
||||
@app.get("/api/v1/prices/latest")
|
||||
def get_prices_latest(_: object = Depends(require_session)):
|
||||
generated_at = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return {
|
||||
"generated_at": generated_at,
|
||||
"datasets": {
|
||||
"metals": get_latest(None),
|
||||
"live_commodities": get_live(None),
|
||||
"currency": currency_stream.get_latest(),
|
||||
"gold": currency_stream.get_gold(),
|
||||
"coin": currency_stream.get_coin(),
|
||||
"commodity": get_commodity_latest(),
|
||||
"steel_stocks": steel_stocks.get_latest(),
|
||||
"crypto": crypto_market.get_latest(),
|
||||
"world_economy": world_economy.get_latest(),
|
||||
},
|
||||
}
|
||||
|
||||
@app.get("/api/currency")
|
||||
def get_currency(_: object = Depends(require_session)):
|
||||
return currency_stream.get_latest()
|
||||
|
||||
@app.get("/api/gold")
|
||||
def get_gold(_: object = Depends(require_session)):
|
||||
return currency_stream.get_gold()
|
||||
|
||||
@app.get("/api/coin")
|
||||
def get_coin(_: object = Depends(require_session)):
|
||||
return currency_stream.get_coin()
|
||||
|
||||
@app.get("/api/world-economy")
|
||||
def get_world_economy(_: object = Depends(require_session)):
|
||||
return world_economy.get_latest()
|
||||
|
||||
@app.get("/api/steel-stocks")
|
||||
def get_steel_stocks(_: object = Depends(require_session)):
|
||||
return steel_stocks.get_latest()
|
||||
|
||||
@app.get("/api/crypto")
|
||||
def get_crypto(_: object = Depends(require_session)):
|
||||
return crypto_market.get_latest()
|
||||
|
||||
@app.get("/api/reports")
|
||||
def get_reports(_: object = Depends(require_session)):
|
||||
return reports.get_index()
|
||||
|
||||
@app.get("/api/reports/files")
|
||||
def get_report_files(folder: str = Query(...), _: object = Depends(require_session)):
|
||||
return reports.list_files(folder)
|
||||
|
||||
@app.get("/api/reports/file")
|
||||
def get_report_file(folder: str = Query(...), name: str = Query(...), _: object = Depends(require_session)):
|
||||
p = reports.file_path(folder, name)
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="report not found")
|
||||
return FileResponse(p, media_type="application/pdf", content_disposition_type="inline")
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE,
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
max_age=auth.SESSION_DAYS * 86400,
|
||||
path="/",
|
||||
)
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=80)
|
||||
last_name: str = Field(..., min_length=1, max_length=80)
|
||||
organization: str = Field(..., min_length=1, max_length=160)
|
||||
email: str = Field(..., max_length=254)
|
||||
phone: str = Field(..., min_length=6, max_length=24)
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
@app.post("/api/register")
|
||||
def register(data: RegisterIn):
|
||||
try:
|
||||
user_id = auth.create_user(
|
||||
name=f"{data.name.strip()} {data.last_name.strip()}",
|
||||
username=data.phone.strip(),
|
||||
password=data.password,
|
||||
email=data.email.strip().lower(),
|
||||
organization=data.organization.strip(),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"ok": True, "id": user_id}
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
identifier: str = Field(..., min_length=1, max_length=254)
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
@app.post("/api/login")
|
||||
def login(data: LoginIn, response: Response):
|
||||
ident = data.identifier.strip()
|
||||
ident_l = ident.lower()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, password_hash, status FROM users "
|
||||
"WHERE lower(username)=? OR lower(email)=? OR username=?",
|
||||
(ident_l, ident_l, ident),
|
||||
).fetchone()
|
||||
if (
|
||||
not row
|
||||
or not auth.verify_password(data.password, row["password_hash"])
|
||||
or row["status"] != "approved"
|
||||
):
|
||||
raise HTTPException(status_code=401, detail="نام کاربری/شماره یا رمز عبور اشتباه است")
|
||||
_set_session_cookie(response, auth.create_session(row["id"]))
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/me")
|
||||
def me(request: Request):
|
||||
row = auth.user_for_token(request.cookies.get(SESSION_COOKIE))
|
||||
if not row:
|
||||
return {"id": "public", "name": "public_user", "email": "public@guest", "status": "approved"}
|
||||
return {"id": row["id"], "name": row["name"], "email": row["email"], "status": row["status"]}
|
||||
|
||||
@app.post("/api/logout")
|
||||
def logout(request: Request, response: Response):
|
||||
auth.delete_session(request.cookies.get(SESSION_COOKIE))
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
SSO_SECRET = os.environ.get("SSO_SECRET", "")
|
||||
|
||||
def _verify_sso_token(t: str):
|
||||
if not SSO_SECRET:
|
||||
return None
|
||||
try:
|
||||
raw_body, _, sig = t.partition(".")
|
||||
expected = hmac.new(SSO_SECRET.encode(), raw_body.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
payload = json.loads(base64.urlsafe_b64decode(raw_body.encode() + b"==").decode())
|
||||
return payload
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@app.get("/api/sso")
|
||||
def sso_login(t: str = Query(...), response: Response = None):
|
||||
payload = _verify_sso_token(t)
|
||||
if not payload:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
email = payload.get("email", "").lower()
|
||||
name = payload.get("name", "کاربر اندیشکده")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE lower(email)=?", (email,)).fetchone()
|
||||
if not row:
|
||||
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
conn.execute(
|
||||
"INSERT INTO users(name, username, password_hash, status, created_at, email) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
(name, email, auth.hash_password("sso_login_dummy"), "approved", now, email),
|
||||
)
|
||||
row = conn.execute("SELECT id FROM users WHERE lower(email)=?", (email,)).fetchone()
|
||||
_set_session_cookie(response, auth.create_session(row["id"]))
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
data_api = APIRouter(prefix="/api/v1")
|
||||
|
||||
@data_api.get("/prices/latest")
|
||||
def v1_prices_latest(): return get_prices_latest(None)
|
||||
|
||||
@data_api.get("/live")
|
||||
def v1_live(): return get_live(None)
|
||||
|
||||
@data_api.get("/latest")
|
||||
def v1_latest(): return get_latest(None)
|
||||
|
||||
@data_api.get("/commodity")
|
||||
def v1_commodity(): return get_commodity(None)
|
||||
|
||||
@data_api.get("/currency")
|
||||
def v1_currency(): return get_currency(None)
|
||||
|
||||
@data_api.get("/gold")
|
||||
def v1_gold(): return get_gold(None)
|
||||
|
||||
@data_api.get("/coin")
|
||||
def v1_coin(): return get_coin(None)
|
||||
|
||||
@data_api.get("/metals/tree")
|
||||
def v1_metals_tree(): return get_tree(None)
|
||||
|
||||
@data_api.get("/metals/latest")
|
||||
def v1_metals_latest(): return get_latest(None)
|
||||
|
||||
@data_api.get("/metals/history")
|
||||
def v1_metals_history(title: str = Query(...), days: int = 0): return get_history(title, days, None)
|
||||
|
||||
@data_api.get("/metals/search")
|
||||
def v1_metals_search(q: str = Query(...)): return search(q, None)
|
||||
|
||||
@data_api.get("/steel-stocks")
|
||||
def v1_steel_stocks(): return get_steel_stocks(None)
|
||||
|
||||
@data_api.get("/crypto")
|
||||
def v1_crypto(): return get_crypto(None)
|
||||
|
||||
@data_api.get("/world-economy")
|
||||
def v1_world_economy(): return get_world_economy(None)
|
||||
|
||||
app.include_router(data_api)
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
"""
|
||||
Report library — scans the on-disk archive of PDF market reports and exposes
|
||||
a small summary (sources -> categories -> counts/latest) plus a per-category
|
||||
file listing and inline PDF streaming.
|
||||
|
||||
Folder layout (one level deep):
|
||||
<REPORTS_DIR>/<Source> - <Category>/<file>.pdf
|
||||
e.g. "FerroAlloyNet - Iron Ore/iron ore daily report (07022025).pdf"
|
||||
|
||||
Dates live inside the filenames (mtime is just the copy date and is useless for
|
||||
sorting), so we parse them out. Two formats are present in the wild:
|
||||
DDMMYYYY -> "(07022025)" = 2025-02-07
|
||||
YYYYMMDD -> "(20240523)" = 2024-05-23
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
REPORTS_DIR = os.environ.get(
|
||||
"REPORTS_DIR", r"C:\Users\DATIS STAR\Desktop\asianmetal_reports"
|
||||
)
|
||||
KNOWN_SOURCES = ["Fastmarkets", "FerroAlloyNet", "Delphica", "Platts", "Baiinfo"]
|
||||
|
||||
_CACHE: dict = {"ts": 0.0, "index": None}
|
||||
_TTL = 600 # re-scan disk at most every 10 minutes
|
||||
_DATE_RE = re.compile(r"(\d{8})")
|
||||
|
||||
|
||||
def _split_source(folder: str):
|
||||
"""'FerroAlloyNet - Iron Ore' -> ('FerroAlloyNet', 'Iron Ore'); 'Baiinfo China' -> ('Baiinfo', 'China')."""
|
||||
if " - " in folder:
|
||||
s, c = folder.split(" - ", 1)
|
||||
return s.strip(), c.strip()
|
||||
for s in KNOWN_SOURCES:
|
||||
if folder.startswith(s):
|
||||
rest = folder[len(s):].strip()
|
||||
return s, (rest or s)
|
||||
parts = folder.split(" ", 1)
|
||||
return parts[0], (parts[1] if len(parts) > 1 else parts[0])
|
||||
|
||||
|
||||
def _parse_date(name: str):
|
||||
"""Best-effort report date from a filename. Returns 'YYYY-MM-DD' or None."""
|
||||
m = _DATE_RE.search(name)
|
||||
if not m:
|
||||
return None
|
||||
s = m.group(1)
|
||||
candidates = [
|
||||
(int(s[0:4]), int(s[4:6]), int(s[6:8])), # YYYYMMDD
|
||||
(int(s[4:8]), int(s[2:4]), int(s[0:2])), # DDMMYYYY
|
||||
(int(s[4:8]), int(s[0:2]), int(s[2:4])), # MMDDYYYY
|
||||
]
|
||||
for y, mo, d in candidates:
|
||||
if 2000 <= y <= 2099 and 1 <= mo <= 12 and 1 <= d <= 31:
|
||||
try:
|
||||
return datetime(y, mo, d).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _kind(name: str) -> str:
|
||||
n = name.lower()
|
||||
if "annual" in n:
|
||||
return "annual"
|
||||
if "weekly" in n:
|
||||
return "weekly"
|
||||
if "monthly" in n:
|
||||
return "monthly"
|
||||
if "daily" in n:
|
||||
return "daily"
|
||||
return "report"
|
||||
|
||||
|
||||
def _file_record(entry, source: str, category: str, folder: str) -> dict:
|
||||
try:
|
||||
size = entry.stat().st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
return {
|
||||
"name": entry.name,
|
||||
"date": _parse_date(entry.name),
|
||||
"size": size,
|
||||
"kind": _kind(entry.name),
|
||||
"source": source,
|
||||
"category": category,
|
||||
"folder": folder,
|
||||
}
|
||||
|
||||
|
||||
def _scan() -> dict:
|
||||
if not os.path.isdir(REPORTS_DIR):
|
||||
return {"sources": [], "latest": [], "total": 0, "dir": REPORTS_DIR, "exists": False}
|
||||
|
||||
sources: dict = {}
|
||||
flat: list = []
|
||||
|
||||
for entry in os.scandir(REPORTS_DIR):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
source, category = _split_source(entry.name)
|
||||
files = []
|
||||
try:
|
||||
for f in os.scandir(entry.path):
|
||||
if f.is_file() and f.name.lower().endswith(".pdf"):
|
||||
rec = _file_record(f, source, category, entry.name)
|
||||
files.append(rec)
|
||||
flat.append(rec)
|
||||
except OSError:
|
||||
continue
|
||||
files.sort(key=lambda r: (r["date"] or "0000-00-00"), reverse=True)
|
||||
sources.setdefault(source, {})[category] = {
|
||||
"folder": entry.name,
|
||||
"category": category,
|
||||
"count": len(files),
|
||||
"latest": files[0]["date"] if files else None,
|
||||
"preview": files[:3],
|
||||
}
|
||||
|
||||
src_list = []
|
||||
for source, cats in sources.items():
|
||||
cat_list = sorted(cats.values(), key=lambda c: c["category"])
|
||||
dates = [c["latest"] for c in cat_list if c["latest"]]
|
||||
src_list.append({
|
||||
"source": source,
|
||||
"categories": cat_list,
|
||||
"count": sum(c["count"] for c in cat_list),
|
||||
"category_count": len(cat_list),
|
||||
"latest": max(dates) if dates else None,
|
||||
})
|
||||
src_list.sort(key=lambda s: s["count"], reverse=True)
|
||||
flat.sort(key=lambda r: (r["date"] or "0000-00-00"), reverse=True)
|
||||
|
||||
return {
|
||||
"sources": src_list,
|
||||
"latest": flat[:30],
|
||||
"total": len(flat),
|
||||
"dir": REPORTS_DIR,
|
||||
"exists": True,
|
||||
}
|
||||
|
||||
|
||||
def get_index(force: bool = False) -> dict:
|
||||
now = time.time()
|
||||
if force or _CACHE["index"] is None or now - _CACHE["ts"] > _TTL:
|
||||
_CACHE["index"] = _scan()
|
||||
_CACHE["ts"] = now
|
||||
return _CACHE["index"]
|
||||
|
||||
|
||||
def list_files(folder: str) -> list:
|
||||
"""Full PDF listing for a single category folder (newest first)."""
|
||||
base = os.path.realpath(REPORTS_DIR)
|
||||
path = os.path.realpath(os.path.join(REPORTS_DIR, folder))
|
||||
if path != base and not path.startswith(base + os.sep):
|
||||
return []
|
||||
if not os.path.isdir(path):
|
||||
return []
|
||||
source, category = _split_source(folder)
|
||||
out = []
|
||||
for f in os.scandir(path):
|
||||
if f.is_file() and f.name.lower().endswith(".pdf"):
|
||||
out.append(_file_record(f, source, category, folder))
|
||||
out.sort(key=lambda r: (r["date"] or "0000-00-00"), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def file_path(folder: str, name: str):
|
||||
"""Resolve a safe absolute path to a report PDF, or None if out of bounds/missing."""
|
||||
base = os.path.realpath(REPORTS_DIR)
|
||||
p = os.path.realpath(os.path.join(REPORTS_DIR, folder, name))
|
||||
if not p.startswith(base + os.sep):
|
||||
return None
|
||||
if not os.path.isfile(p) or not p.lower().endswith(".pdf"):
|
||||
return None
|
||||
return p
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fastapi
|
||||
uvicorn[standard]
|
||||
pydantic
|
||||
playwright==1.46.0
|
||||
requests
|
||||
yfinance
|
||||
pandas
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"""
|
||||
Scrapes commodity prices from tradingeconomics.com/commodities
|
||||
using a headless browser. Saves to the SQLite DB.
|
||||
Run every 10 minutes via cron / Task Scheduler.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
|
||||
from db import get_conn, init_db
|
||||
|
||||
URL = "https://tradingeconomics.com/commodities"
|
||||
|
||||
def scrape() -> list[dict]:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
page.goto(URL, wait_until="domcontentloaded", timeout=30000)
|
||||
try:
|
||||
page.wait_for_selector("table", timeout=15000)
|
||||
except PWTimeout:
|
||||
browser.close()
|
||||
return []
|
||||
|
||||
rows = page.evaluate("""() => {
|
||||
const results = [];
|
||||
document.querySelectorAll("table tr").forEach(tr => {
|
||||
const cells = Array.from(tr.querySelectorAll("td"));
|
||||
if (cells.length < 4) return;
|
||||
const name = cells[0]?.innerText?.trim();
|
||||
const price = cells[1]?.innerText?.trim();
|
||||
const day = cells[2]?.innerText?.trim();
|
||||
const pct = cells[3]?.innerText?.trim();
|
||||
const unit = cells[4]?.innerText?.trim() || "";
|
||||
if (name && price && !isNaN(parseFloat(price.replace(/,/g,"")))) {
|
||||
results.push({ name, price, day, pct, unit });
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}""")
|
||||
|
||||
browser.close()
|
||||
return rows
|
||||
|
||||
def save(rows: list[dict]):
|
||||
if not rows:
|
||||
print("No data scraped")
|
||||
return
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
with get_conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS te_prices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
price REAL,
|
||||
day_change TEXT,
|
||||
pct_change TEXT,
|
||||
unit TEXT,
|
||||
fetched_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_te_name ON te_prices(name)")
|
||||
conn.executemany(
|
||||
"INSERT INTO te_prices(name, price, day_change, pct_change, unit, fetched_at) VALUES(?,?,?,?,?,?)",
|
||||
[(r["name"], float(r["price"].replace(",","")), r["day"], r["pct"], r["unit"], now)
|
||||
for r in rows]
|
||||
)
|
||||
print(f"Saved {len(rows)} prices at {now}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
rows = scrape()
|
||||
print(f"Scraped {len(rows)} rows")
|
||||
for r in rows[:8]:
|
||||
print(r)
|
||||
save(rows)
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"""
|
||||
Scrapes live Iranian market prices from tgju.org.
|
||||
Tables: gold, coins, currency, melted gold, silver, oil
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from playwright.sync_api import sync_playwright
|
||||
from db import get_conn, init_db
|
||||
|
||||
URL = "https://www.tgju.org/gold-chart"
|
||||
|
||||
def create_table():
|
||||
with get_conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tgju_prices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL, price TEXT, change_val TEXT,
|
||||
pct_change TEXT, low TEXT, high TEXT,
|
||||
fetched_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tgju_name ON tgju_prices(name)")
|
||||
|
||||
def scrape() -> list[dict]:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
page.goto(URL, wait_until="networkidle", timeout=30000)
|
||||
|
||||
rows = page.evaluate("""() => {
|
||||
const results = [];
|
||||
document.querySelectorAll("table.data-table.market-table").forEach(table => {
|
||||
Array.from(table.querySelectorAll("tbody tr")).forEach(tr => {
|
||||
const cells = Array.from(tr.querySelectorAll("td"));
|
||||
if (cells.length < 2) return;
|
||||
const name = cells[0]?.innerText?.trim();
|
||||
const price = cells[1]?.innerText?.trim();
|
||||
const change = cells[2]?.innerText?.trim() || "";
|
||||
const low = cells[3]?.innerText?.trim() || "";
|
||||
const high = cells[4]?.innerText?.trim() || "";
|
||||
if (name && price && price !== "-") {
|
||||
results.push({ name, price, change, low, high });
|
||||
}
|
||||
});
|
||||
});
|
||||
return results;
|
||||
}""")
|
||||
|
||||
browser.close()
|
||||
return rows
|
||||
|
||||
def save(rows: list[dict]):
|
||||
if not rows:
|
||||
print("No data")
|
||||
return
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
with get_conn() as conn:
|
||||
conn.executemany(
|
||||
"INSERT INTO tgju_prices(name,price,change_val,pct_change,low,high,fetched_at) VALUES(?,?,?,?,?,?,?)",
|
||||
[(r["name"], r["price"], r["change"], "", r["low"], r["high"], now) for r in rows]
|
||||
)
|
||||
print(f"Saved {len(rows)} rows at {now}")
|
||||
for r in rows[:8]:
|
||||
print(f" {r['name']}: {r['price']} ({r['change']})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
create_table()
|
||||
rows = scrape()
|
||||
print(f"Scraped {len(rows)} rows")
|
||||
save(rows)
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"""Focused regression checks for authentication and proxy hardening."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix="steelstatista-security-")
|
||||
os.environ["DB_PATH"] = os.path.join(tmp_dir, "security-test.db")
|
||||
os.environ["REPORTS_DIR"] = os.path.join(tmp_dir, "reports")
|
||||
os.environ["SEED_ADMIN"] = "0"
|
||||
os.environ["COOKIE_SECURE"] = "0"
|
||||
os.environ["ALLOWED_ORIGINS"] = "http://localhost:5174,https://web.didvan.com"
|
||||
os.environ["API_TOKEN"] = "didvan-regression-token"
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import main
|
||||
from db import get_conn
|
||||
|
||||
client = TestClient(main.app)
|
||||
|
||||
for path in (
|
||||
"/api/latest",
|
||||
"/api/currency",
|
||||
"/api/reports",
|
||||
"/api/reports/file?folder=x&name=x.pdf",
|
||||
):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 401, (path, response.status_code, response.text)
|
||||
|
||||
legacy = client.get("/api/sso?t=credential-must-not-be-read", follow_redirects=False)
|
||||
assert legacy.status_code == 303, legacy.text
|
||||
assert legacy.headers["location"] == "/login"
|
||||
|
||||
partner = client.get(
|
||||
"/api/v1/prices/latest",
|
||||
headers={"Authorization": "Bearer didvan-regression-token"},
|
||||
)
|
||||
assert partner.status_code == 200, partner.text
|
||||
|
||||
partner_preflight = client.options(
|
||||
"/api/v1/prices/latest",
|
||||
headers={
|
||||
"Origin": "https://web.didvan.com",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
"Access-Control-Request-Headers": "authorization",
|
||||
},
|
||||
)
|
||||
assert partner_preflight.status_code == 200, partner_preflight.text
|
||||
assert partner_preflight.headers["access-control-allow-origin"] == "https://web.didvan.com"
|
||||
|
||||
assert main._resolved_client_ip("203.0.113.8", "1.2.3.4", None) == "203.0.113.8"
|
||||
assert main._resolved_client_ip("127.0.0.1", "1.2.3.4", None) == "1.2.3.4"
|
||||
|
||||
with get_conn() as conn:
|
||||
assert not conn.execute("SELECT 1 FROM users WHERE lower(username)='admin'").fetchone()
|
||||
|
||||
print("security smoke tests passed")
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
"""
|
||||
Scrapes the largest steel-industry companies by market cap from
|
||||
companiesmarketcap.com every 5 minutes. Keeps a current snapshot in memory
|
||||
(served by /api/steel-stocks) and upserts one row per company in steel_stocks.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from playwright.sync_api import sync_playwright
|
||||
from db import get_conn
|
||||
|
||||
# companiesmarketcap.com is geo-blocked from Iran; route the headless browser
|
||||
# through an outbound proxy when one is configured (SCRAPER_PROXY / HTTPS_PROXY).
|
||||
def _proxy_arg() -> dict:
|
||||
server = (
|
||||
os.environ.get("SCRAPER_PROXY")
|
||||
or os.environ.get("HTTPS_PROXY")
|
||||
or os.environ.get("HTTP_PROXY")
|
||||
)
|
||||
return {"proxy": {"server": server}} if server else {}
|
||||
|
||||
URL = "https://companiesmarketcap.com/steel-industry/largest-companies-by-market-cap/"
|
||||
UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
INTERVAL_SECONDS = 5 * 60
|
||||
|
||||
_latest: list[dict] = []
|
||||
|
||||
|
||||
def create_table() -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS steel_stocks (
|
||||
code TEXT PRIMARY KEY,
|
||||
rank INTEGER,
|
||||
name TEXT,
|
||||
market_cap REAL,
|
||||
price REAL,
|
||||
today_pct REAL,
|
||||
dir TEXT,
|
||||
country TEXT,
|
||||
fetched_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _money(s):
|
||||
"""'$254.39' -> 254.39 ; '$2,000' -> 2000.0"""
|
||||
try:
|
||||
return float(str(s).replace("$", "").replace(",", "").strip())
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _market_cap_b(s):
|
||||
"""'$57.93 B' -> 57.93 ; '$850.00 M' -> 0.85 ; '$1.20 T' -> 1200.0 (billions)"""
|
||||
t = str(s).replace("$", "").replace(",", "").strip().upper()
|
||||
mult = 1.0
|
||||
if t.endswith("T"):
|
||||
t, mult = t[:-1], 1000.0
|
||||
elif t.endswith("B"):
|
||||
t, mult = t[:-1], 1.0
|
||||
elif t.endswith("M"):
|
||||
t, mult = t[:-1], 0.001
|
||||
try:
|
||||
return round(float(t.strip()) * mult, 4)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _scrape_sync() -> list[dict]:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, **_proxy_arg())
|
||||
page = browser.new_page(user_agent=UA)
|
||||
try:
|
||||
page.goto(URL, wait_until="domcontentloaded", timeout=45000)
|
||||
page.wait_for_selector("table tbody tr", timeout=20000)
|
||||
# Prices load via JS after the table renders — wait so they're populated.
|
||||
page.wait_for_timeout(5000)
|
||||
raw = page.evaluate(r"""() => {
|
||||
const out = [];
|
||||
document.querySelectorAll("table tbody tr").forEach(tr => {
|
||||
const tds = tr.querySelectorAll("td");
|
||||
if (tds.length < 8) return;
|
||||
const name = (tr.querySelector(".company-name")?.innerText || "").trim();
|
||||
const code = (tr.querySelector(".company-code")?.innerText || "").trim();
|
||||
if (!name) return;
|
||||
const chg = tds[5];
|
||||
const blob = ((chg?.innerHTML || "") + " " + (chg?.className || "")).toLowerCase();
|
||||
let dir = "";
|
||||
if (blob.includes("green")) dir = "up";
|
||||
else if (blob.includes("red")) dir = "down";
|
||||
out.push({
|
||||
rank: (tds[1]?.innerText || "").trim(),
|
||||
name, code,
|
||||
market_cap: (tds[3]?.innerText || "").trim(),
|
||||
price: (tds[4]?.innerText || "").trim(),
|
||||
today: (chg?.innerText || "").trim(),
|
||||
dir,
|
||||
country: (tds[7]?.innerText || "").trim(),
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}""")
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
rows = []
|
||||
for r in raw:
|
||||
try:
|
||||
rank = int(r["rank"])
|
||||
except (ValueError, TypeError):
|
||||
rank = None
|
||||
pct = _money(str(r["today"]).replace("%", ""))
|
||||
if pct is not None and r["dir"] == "down":
|
||||
pct = -pct
|
||||
rows.append({
|
||||
"code": r["code"],
|
||||
"rank": rank,
|
||||
"name": r["name"],
|
||||
"market_cap": _market_cap_b(r["market_cap"]),
|
||||
"price": _money(r["price"]),
|
||||
"today_pct": pct,
|
||||
"dir": r["dir"],
|
||||
"country": r["country"],
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _save(rows: list[dict], now: str) -> None:
|
||||
with get_conn() as conn:
|
||||
for r in rows:
|
||||
if not r["code"]:
|
||||
continue
|
||||
conn.execute(
|
||||
"""INSERT INTO steel_stocks
|
||||
(code, rank, name, market_cap, price, today_pct, dir, country, fetched_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
rank=excluded.rank, name=excluded.name, market_cap=excluded.market_cap,
|
||||
price=excluded.price, today_pct=excluded.today_pct, dir=excluded.dir,
|
||||
country=excluded.country, fetched_at=excluded.fetched_at""",
|
||||
(r["code"], r["rank"], r["name"], r["market_cap"], r["price"],
|
||||
r["today_pct"], r["dir"], r["country"], now),
|
||||
)
|
||||
|
||||
|
||||
def _load_from_db() -> None:
|
||||
global _latest
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM steel_stocks ORDER BY rank IS NULL, rank"
|
||||
).fetchall()
|
||||
if rows:
|
||||
_latest = [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
print(f"[steel] could not seed from DB: {e}")
|
||||
|
||||
|
||||
async def scrape_loop() -> None:
|
||||
global _latest
|
||||
create_table()
|
||||
_load_from_db()
|
||||
while True:
|
||||
try:
|
||||
rows = await asyncio.to_thread(_scrape_sync)
|
||||
if rows:
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
await asyncio.to_thread(_save, rows, now)
|
||||
rows.sort(key=lambda r: (r["rank"] is None, r["rank"] or 9999))
|
||||
_latest = [{**r, "fetched_at": now} for r in rows]
|
||||
print(f"[steel] {len(rows)} companies at {now}")
|
||||
except Exception as e:
|
||||
print(f"[steel] error: {e}")
|
||||
await asyncio.sleep(INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def get_latest() -> list[dict]:
|
||||
return _latest
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
"""
|
||||
Daily sync: fetches latest prices from SteelStatista.com API and inserts
|
||||
only NEW (title, date) pairs into the SQLite database.
|
||||
Run manually or via Windows Task Scheduler once a day.
|
||||
"""
|
||||
import os
|
||||
import requests
|
||||
import time
|
||||
from db import get_conn, init_db
|
||||
|
||||
# The data-source token is NEVER hardcoded. Set it via the admin panel
|
||||
# (stored in app_config) or the ASIANMETAL_TOKEN environment variable.
|
||||
HEADERS = {
|
||||
"lang": "fa", "platform": "web", "accept": "application/json",
|
||||
"referer": "https://SteelStatista.com/",
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
}
|
||||
|
||||
BASE = "https://api.SteelStatista.com"
|
||||
|
||||
CAT_IDS = [9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,
|
||||
31,32,33,34,35,36,37,38,39,40,42,43,44,45,46,47,48,49,50,51,52,53,
|
||||
54,55,56,57,65,67,68,70,71,72,74,75,76,77,78,79,80,81,82,83,84,85,
|
||||
87,88,89,90,92,93,94,95,96,97,98,99,100,101,102,103,104,105]
|
||||
|
||||
CAT_NAMES = {1:"Base Metals",2:"Minor Metals",3:"Ferroalloys",4:"Rare Earths",
|
||||
5:"Carbon Steel",6:"Stainless & Special",7:"Steel Raw Materials",8:"Refractories",
|
||||
9:"Aluminum",10:"Copper",11:"Lead",12:"Nickel",13:"Tin",14:"Zinc",
|
||||
15:"Antimony",16:"Arsenic",17:"Bismuth",18:"Cadmium",19:"Calcium",
|
||||
20:"Chromium",21:"Cobalt",22:"Gallium",23:"Germanium",24:"Indium",
|
||||
25:"Lithium",26:"Magnesium",27:"Manganese",28:"Mercury",29:"Molybdenum",
|
||||
30:"Niobium",31:"Rhenium",32:"Selenium",33:"Silicon",34:"Strontium",
|
||||
35:"Tantalum",36:"Tellurium",37:"Titanium",38:"Tungsten",39:"Vanadium",
|
||||
40:"Zirconium",42:"Ferroboron",43:"Ferrochrome",44:"Ferromanganese",
|
||||
45:"Ferromolybdenum",46:"Ferronickel",47:"Ferroniobium",48:"Ferrophosphorus",
|
||||
49:"Ferrosilicon",50:"Ferrotitanium",51:"Ferrotungsten",52:"Ferrovanadium",
|
||||
53:"Silicomanganese",54:"Chromium Silicon",55:"Calcium Silicon",
|
||||
56:"Chrome Ore",57:"Manganese Ore",65:"Lutetium",67:"Neodymium",
|
||||
68:"Praseodymium",70:"Samarium",71:"Scandium",72:"Terbium",
|
||||
74:"Ytterbium",75:"Yttrium",76:"Wire Rod",77:"Rebar",78:"Sections",
|
||||
79:"Pipe",80:"Hot Rolled Coil",81:"Cold Rolled Coil",82:"Plate",
|
||||
83:"Coated",84:"Strip",85:"Stainless Bar",87:"Stainless Pipe",
|
||||
88:"Stainless Scrap",89:"Bearing Steel",90:"Cold Heading Steel",
|
||||
92:"Silicon Steel",93:"Structural Steel",94:"Coal",95:"Coke",
|
||||
96:"Iron",97:"Iron Ore",98:"Steel Billet",99:"Steel Scrap",
|
||||
100:"Calcined Bauxite",101:"Carbon",102:"Fused Alumina",
|
||||
103:"Graphite",104:"Magnesia",105:"Silicon Carbide"}
|
||||
|
||||
PARENT_MAP = {9:1,10:1,11:1,12:1,13:1,14:1,15:2,16:2,17:2,18:2,19:2,20:2,
|
||||
21:2,22:2,23:2,24:2,25:2,26:2,27:2,28:2,29:2,30:2,31:2,32:2,
|
||||
33:2,34:2,35:2,36:2,37:2,38:2,39:2,40:2,42:3,43:3,44:3,45:3,
|
||||
46:3,47:3,48:3,49:3,50:3,51:3,52:3,53:3,54:3,55:3,56:3,57:3,
|
||||
65:4,67:4,68:4,70:4,71:4,72:4,74:4,75:4,76:5,77:5,78:5,79:5,
|
||||
80:5,81:5,82:5,83:5,84:5,85:6,87:6,88:6,89:6,90:6,92:6,93:6,
|
||||
94:7,95:7,96:7,97:7,98:7,99:7,100:8,101:8,102:8,103:8,104:8,105:8}
|
||||
|
||||
def get_latest_dates(conn) -> dict:
|
||||
rows = conn.execute("SELECT title, MAX(date) as d FROM prices GROUP BY title").fetchall()
|
||||
return {r["title"]: r["d"] for r in rows}
|
||||
|
||||
def get_token() -> str:
|
||||
"""Read the SteelStatista token from app_config (editable via the admin panel);
|
||||
fall back to the bundled default if not set."""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_config WHERE key='asianmetal_token'"
|
||||
).fetchone()
|
||||
if row and row["value"]:
|
||||
return row["value"]
|
||||
except Exception:
|
||||
pass
|
||||
return os.environ.get("ASIANMETAL_TOKEN", "")
|
||||
|
||||
|
||||
def sync():
|
||||
init_db()
|
||||
session = requests.Session()
|
||||
session.headers.update(HEADERS)
|
||||
session.headers["Authorization"] = f"Bearer {get_token()}"
|
||||
|
||||
with get_conn() as conn:
|
||||
latest_dates = get_latest_dates(conn)
|
||||
|
||||
print("Collecting symbols...")
|
||||
symbols = []
|
||||
for cat_id in CAT_IDS:
|
||||
try:
|
||||
r = session.get(f"{BASE}/Symbol?categoryId={cat_id}", timeout=10)
|
||||
for sym in r.json():
|
||||
symbols.append({
|
||||
"symbol_id": sym["id"],
|
||||
"grp": CAT_NAMES.get(PARENT_MAP.get(cat_id, 0), ""),
|
||||
"category": CAT_NAMES.get(cat_id, str(cat_id)),
|
||||
"title": sym["title"],
|
||||
})
|
||||
except Exception as e:
|
||||
print(f" Error cat {cat_id}: {e}")
|
||||
time.sleep(0.15)
|
||||
|
||||
print(f"Found {len(symbols)} symbols. Fetching new prices...")
|
||||
|
||||
inserted = 0
|
||||
for i, sym in enumerate(symbols):
|
||||
title = sym["title"]
|
||||
cutoff = latest_dates.get(title)
|
||||
|
||||
try:
|
||||
r = session.get(f"{BASE}/Symbol/{sym['symbol_id']}", timeout=10)
|
||||
prices = r.json().get("symbolPrices", [])
|
||||
|
||||
rows_to_insert = [
|
||||
(sym["grp"], sym["category"], title, p["date"][:10], p["low"], p["mid"], p["high"])
|
||||
for p in prices
|
||||
if not (cutoff and p["date"][:10] <= cutoff)
|
||||
]
|
||||
|
||||
if rows_to_insert:
|
||||
with get_conn() as conn:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO prices(grp,category,title,date,low,mid,high) VALUES(?,?,?,?,?,?,?)",
|
||||
rows_to_insert
|
||||
)
|
||||
inserted += len(rows_to_insert)
|
||||
|
||||
if (i + 1) % 100 == 0:
|
||||
print(f" {i+1}/{len(symbols)} done — {inserted} new rows so far")
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error symbol {sym['symbol_id']}: {e}")
|
||||
time.sleep(0.2)
|
||||
|
||||
print(f"\nSync complete — {inserted} new rows inserted")
|
||||
|
||||
if __name__ == "__main__":
|
||||
sync()
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
"""
|
||||
Scrapes the world-economy indicators matrix from Trading Economics
|
||||
(tradingeconomics.com/matrix) — the same data behind TradingView's
|
||||
"world-economy indicators heatmap". Macro data changes slowly, so this
|
||||
refreshes every few hours. Caches the leading economies in memory
|
||||
(served by /api/world-economy) and persists all countries.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from playwright.sync_api import sync_playwright
|
||||
from db import get_conn
|
||||
|
||||
# Trading Economics is geo-blocked from Iran; route the headless browser through
|
||||
# an outbound proxy when one is configured (SCRAPER_PROXY / HTTPS_PROXY).
|
||||
def _proxy_arg() -> dict:
|
||||
server = (
|
||||
os.environ.get("SCRAPER_PROXY")
|
||||
or os.environ.get("HTTPS_PROXY")
|
||||
or os.environ.get("HTTP_PROXY")
|
||||
)
|
||||
return {"proxy": {"server": server}} if server else {}
|
||||
|
||||
URL = "https://tradingeconomics.com/matrix"
|
||||
UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
INTERVAL_SECONDS = 7 * 24 * 3600 # 1 week (macro data changes slowly)
|
||||
|
||||
# Leading economies to surface (must match Trading Economics' country names).
|
||||
COUNTRIES = [
|
||||
"United States", "China", "Euro Area", "Germany", "Japan", "India",
|
||||
"United Kingdom", "France", "Italy", "Canada", "South Korea", "Russia",
|
||||
"Brazil", "Australia", "Spain", "Mexico", "Turkey", "Indonesia",
|
||||
]
|
||||
|
||||
_latest: list[dict] = []
|
||||
|
||||
|
||||
def create_table() -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS world_economy (
|
||||
country TEXT PRIMARY KEY,
|
||||
gdp REAL,
|
||||
gdp_growth REAL,
|
||||
interest_rate REAL,
|
||||
inflation_rate REAL,
|
||||
jobless_rate REAL,
|
||||
gov_budget REAL,
|
||||
debt_gdp REAL,
|
||||
current_account REAL,
|
||||
population REAL,
|
||||
fetched_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _num(s):
|
||||
try:
|
||||
return float(str(s).replace(",", "").replace("%", "").strip())
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _scrape_sync() -> list[dict]:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, **_proxy_arg())
|
||||
page = browser.new_page(user_agent=UA)
|
||||
try:
|
||||
page.goto(URL, wait_until="domcontentloaded", timeout=45000)
|
||||
page.wait_for_selector("table tbody tr", timeout=20000)
|
||||
rows = page.evaluate(r"""() => {
|
||||
const t = document.querySelector("table");
|
||||
if (!t) return [];
|
||||
return Array.from(t.querySelectorAll("tbody tr")).map(tr =>
|
||||
Array.from(tr.querySelectorAll("th,td")).map(c => (c.innerText || "").trim()));
|
||||
}""")
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
out = []
|
||||
for r in rows:
|
||||
if len(r) < 10 or not r[0]:
|
||||
continue
|
||||
out.append({
|
||||
"country": r[0],
|
||||
"gdp": _num(r[1]),
|
||||
"gdp_growth": _num(r[2]),
|
||||
"interest_rate": _num(r[3]),
|
||||
"inflation_rate": _num(r[4]),
|
||||
"jobless_rate": _num(r[5]),
|
||||
"gov_budget": _num(r[6]),
|
||||
"debt_gdp": _num(r[7]),
|
||||
"current_account": _num(r[8]),
|
||||
"population": _num(r[9]),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _save(rows: list[dict], now: str) -> None:
|
||||
with get_conn() as conn:
|
||||
for r in rows:
|
||||
conn.execute(
|
||||
"""INSERT INTO world_economy
|
||||
(country, gdp, gdp_growth, interest_rate, inflation_rate, jobless_rate,
|
||||
gov_budget, debt_gdp, current_account, population, fetched_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(country) DO UPDATE SET
|
||||
gdp=excluded.gdp, gdp_growth=excluded.gdp_growth,
|
||||
interest_rate=excluded.interest_rate, inflation_rate=excluded.inflation_rate,
|
||||
jobless_rate=excluded.jobless_rate, gov_budget=excluded.gov_budget,
|
||||
debt_gdp=excluded.debt_gdp, current_account=excluded.current_account,
|
||||
population=excluded.population, fetched_at=excluded.fetched_at""",
|
||||
(r["country"], r["gdp"], r["gdp_growth"], r["interest_rate"], r["inflation_rate"],
|
||||
r["jobless_rate"], r["gov_budget"], r["debt_gdp"], r["current_account"],
|
||||
r["population"], now),
|
||||
)
|
||||
|
||||
|
||||
def _leading(rows: list[dict], now: str) -> list[dict]:
|
||||
order = {c: i for i, c in enumerate(COUNTRIES)}
|
||||
kept = [r for r in rows if r["country"] in order]
|
||||
kept.sort(key=lambda r: order[r["country"]])
|
||||
return [{**r, "fetched_at": now} for r in kept]
|
||||
|
||||
|
||||
def _load_from_db() -> None:
|
||||
"""Seed the cache from the DB on startup so the API has data before the
|
||||
first scrape completes."""
|
||||
global _latest
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM world_economy").fetchall()
|
||||
if rows:
|
||||
data = [dict(r) for r in rows]
|
||||
now = data[0].get("fetched_at") or ""
|
||||
_latest = _leading(data, now) or _latest
|
||||
except Exception as e:
|
||||
print(f"[world] could not seed from DB: {e}")
|
||||
|
||||
|
||||
async def scrape_loop() -> None:
|
||||
global _latest
|
||||
create_table()
|
||||
_load_from_db()
|
||||
while True:
|
||||
try:
|
||||
rows = await asyncio.to_thread(_scrape_sync)
|
||||
if rows:
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
await asyncio.to_thread(_save, rows, now)
|
||||
_latest = _leading(rows, now)
|
||||
print(f"[world] {len(_latest)}/{len(rows)} leading economies at {now}")
|
||||
except Exception as e:
|
||||
print(f"[world] error: {e}")
|
||||
await asyncio.sleep(INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def get_latest() -> list[dict]:
|
||||
return _latest
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Copy to deploy/.env and fill in real values — NEVER commit the real .env.
|
||||
# Consumed by deploy/docker-compose.yml on the self-hosted VPS.
|
||||
|
||||
# Admin-panel (admin_app) password — required only if you run the admin service.
|
||||
ADMIN_PASSWORD=change-me-strong-secret
|
||||
|
||||
# Public origin of the deployed site (used for the CORS allow-list).
|
||||
# e.g. http://94.101.187.12 or https://yourdomain.ir
|
||||
PUBLIC_ORIGIN=http://your-server-ip
|
||||
|
||||
# data-source token for the daily metals/steel sync (rotates ~every 4 days).
|
||||
# Leave blank until you have a current one.
|
||||
ASIANMETAL_TOKEN=
|
||||
|
||||
# SSO shared secret. MUST be byte-identical to the steelforesight panel's
|
||||
# SSO_SECRET — anyone holding it can mint a login token for any email. If unset,
|
||||
# SSO degrades to the normal login page (no crash).
|
||||
SSO_SECRET=
|
||||
|
||||
# Bearer token for the internal /api/v1 data API (parent org). Anyone holding it
|
||||
# can read all price data. If unset, /api/v1 returns 503. Generate with e.g.
|
||||
# `python -c "import secrets; print(secrets.token_urlsafe(24))"`.
|
||||
API_TOKEN=
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run this ON the Arvan VPS (inside the repo) to ship the latest code.
|
||||
# ssh <user>@94.101.187.12 'cd /path/to/asianmetal-dashboard && bash deploy/deploy.sh'
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "▶ pulling latest…"
|
||||
git pull --ff-only
|
||||
|
||||
echo "▶ building + (re)starting containers…"
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
|
||||
echo "▶ status:"
|
||||
docker compose -f deploy/docker-compose.yml ps
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
services:
|
||||
backend:
|
||||
container_name: statista-backend-1
|
||||
build:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
environment:
|
||||
- DB_PATH=/data/asianmetal.db
|
||||
- REPORTS_DIR=/data/reports
|
||||
- API_TOKEN=b9660e2c174a5c76b958afdef8cc7d95f71abe23155299832b94984fcadf9f73
|
||||
- API_TOKENS=client:b9660e2c174a5c76b958afdef8cc7d95f71abe23155299832b94984fcadf9f73,didvan:didvan_statista_key_2026,statista_secret_token_2026:b9660e2c174a5c76b958afdef8cc7d95f71abe23155299832b94984fcadf9f73
|
||||
- SSO_SECRET=wSoNQxqlABZylZpXfuvUauUtaWXlNiJUxiOB9lBXOj8
|
||||
- ALLOWED_ORIGINS=https://statista.steelforesight.ir,http://statista.steelforesight.ir,https://steelforesight.ir,http://steelforesight.ir,https://app.didvan.com,http://app.didvan.com,https://iranianfuturist.com,http://iranianfuturist.com,http://94.101.187.12:7860,http://localhost:3000,http://localhost:5173,http://localhost:5174,http://127.0.0.1:5174
|
||||
- TRUSTED_PROXIES=127.0.0.1/32,172.16.0.0/12,10.0.0.0/8,192.168.0.0/16
|
||||
volumes:
|
||||
- statista_data:/data
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
|
||||
frontend:
|
||||
container_name: statista-frontend-1
|
||||
build:
|
||||
context: ../frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
|
||||
nginx:
|
||||
container_name: statista-nginx-1
|
||||
image: nginx:alpine
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./certs:/etc/nginx/certs:ro
|
||||
- ./certs-git:/etc/nginx/certs-git:ro
|
||||
- ./certbot-www:/var/www/certbot:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
statista_data:
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
|
||||
resolver 127.0.0.11 valid=300s ipv6=off;
|
||||
resolver_timeout 5s;
|
||||
|
||||
# 1. Gitea (git.irfartak.com) - HTTP
|
||||
server {
|
||||
listen 80;
|
||||
server_name git.irfartak.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
set $upstream_gitea http://gitea-srv:3000;
|
||||
proxy_pass $upstream_gitea;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Gitea (git.irfartak.com) - HTTPS
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name git.irfartak.com;
|
||||
|
||||
ssl_certificate /etc/nginx/certs-git/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs-git/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
set $upstream_gitea http://gitea-srv:3000;
|
||||
proxy_pass $upstream_gitea;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
|
||||
# 3. Statista (statista.steelforesight.ir) - HTTP
|
||||
server {
|
||||
listen 80;
|
||||
server_name statista.steelforesight.ir;
|
||||
|
||||
location /api/ {
|
||||
set $upstream_backend http://statista-backend-1:8000;
|
||||
proxy_pass $upstream_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
set $upstream_backend http://statista-backend-1:8000;
|
||||
proxy_pass $upstream_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Smart Routing for root /:
|
||||
# If request has Authorization header OR query param token/api_token, serve JSON data (/api/tree).
|
||||
# Otherwise, if it is a normal browser visit, serve React Dashboard UI.
|
||||
location = / {
|
||||
set $backend_target "0";
|
||||
if ($http_authorization ~* ".+") {
|
||||
set $backend_target "1";
|
||||
}
|
||||
if ($arg_api_token ~* ".+") {
|
||||
set $backend_target "1";
|
||||
}
|
||||
if ($arg_token ~* ".+") {
|
||||
set $backend_target "1";
|
||||
}
|
||||
if ($http_accept ~* "application/json") {
|
||||
set $backend_target "1";
|
||||
}
|
||||
|
||||
if ($backend_target = "1") {
|
||||
rewrite ^ /api/tree break;
|
||||
proxy_pass http://statista-backend-1:8000;
|
||||
}
|
||||
|
||||
set $upstream_frontend http://if-react-frontend:80;
|
||||
proxy_pass $upstream_frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
set $upstream_frontend http://if-react-frontend:80;
|
||||
proxy_pass $upstream_frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# 4. Statista (statista.steelforesight.ir) - HTTPS (Port 443)
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name statista.steelforesight.ir;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location /api/ {
|
||||
set $upstream_backend http://statista-backend-1:8000;
|
||||
proxy_pass $upstream_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
set $upstream_backend http://statista-backend-1:8000;
|
||||
proxy_pass $upstream_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
# Smart Routing for root /:
|
||||
# If request has Authorization header OR query param token/api_token, serve JSON data (/api/tree).
|
||||
# Otherwise, if it is a normal browser visit, serve React Dashboard UI.
|
||||
location = / {
|
||||
set $backend_target "0";
|
||||
if ($http_authorization ~* ".+") {
|
||||
set $backend_target "1";
|
||||
}
|
||||
if ($arg_api_token ~* ".+") {
|
||||
set $backend_target "1";
|
||||
}
|
||||
if ($arg_token ~* ".+") {
|
||||
set $backend_target "1";
|
||||
}
|
||||
if ($http_accept ~* "application/json") {
|
||||
set $backend_target "1";
|
||||
}
|
||||
|
||||
if ($backend_target = "1") {
|
||||
rewrite ^ /api/tree break;
|
||||
proxy_pass http://statista-backend-1:8000;
|
||||
}
|
||||
|
||||
set $upstream_frontend http://if-react-frontend:80;
|
||||
proxy_pass $upstream_frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location / {
|
||||
set $upstream_frontend http://if-react-frontend:80;
|
||||
proxy_pass $upstream_frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
|
||||
# 5. Iranian Futurist Main Portal
|
||||
server {
|
||||
listen 80;
|
||||
server_name iranianfuturist.com www.iranianfuturist.com;
|
||||
|
||||
location / {
|
||||
set $upstream_if http://if-react-frontend:80;
|
||||
proxy_pass $upstream_if;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# 6. IRFartak
|
||||
server {
|
||||
listen 80;
|
||||
server_name irfartak.com www.irfartak.com;
|
||||
|
||||
location /api/ {
|
||||
set $upstream_fartak_api http://irfartak-backend-1:3000;
|
||||
proxy_pass $upstream_fartak_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
set $upstream_fartak_fe http://irfartak-frontend-1:3000;
|
||||
proxy_pass $upstream_fartak_fe;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# 7. Default Fallback Server
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
location /statista-api/ {
|
||||
rewrite ^/statista-api/(.*)$ /api/$1 break;
|
||||
set $upstream_backend http://statista-backend-1:8000;
|
||||
proxy_pass $upstream_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
set $upstream_if http://if-react-frontend:80;
|
||||
proxy_pass $upstream_if;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Iranian Futurist - HTTPS
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name iranianfuturist.com www.iranianfuturist.com;
|
||||
|
||||
ssl_certificate /etc/nginx/certs-git/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs-git/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
set $upstream_if http://if-react-frontend:80;
|
||||
proxy_pass $upstream_if;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
|
||||
# 6b. IRFartak .com HTTPS
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name irfartak.com www.irfartak.com;
|
||||
ssl_certificate /etc/nginx/certs-git/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs-git/privkey.pem;
|
||||
location / {
|
||||
set $up_a http://irfartak-frontend-1:3000;
|
||||
proxy_pass $up_a;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# 6c. IRFartak .ir HTTP
|
||||
server {
|
||||
listen 80;
|
||||
server_name irfartak.ir www.irfartak.ir;
|
||||
location / {
|
||||
set $up_b http://irfartak-frontend-1:3000;
|
||||
proxy_pass $up_b;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# 6d. IRFartak .ir HTTPS
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name irfartak.ir www.irfartak.ir;
|
||||
ssl_certificate /etc/nginx/certs-git/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs-git/privkey.pem;
|
||||
location / {
|
||||
set $up_c http://irfartak-frontend-1:3000;
|
||||
proxy_pass $up_c;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# 9. TLS catch-all - unknown SNI must not land on Gitea
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
ssl_certificate /etc/nginx/certs-git/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs-git/privkey.pem;
|
||||
return 444;
|
||||
|
||||
}
|
||||
# --- CMS (Directus) ---
|
||||
server {
|
||||
listen 80;
|
||||
server_name cms.irfartak.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
location / {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $upstream_cms http://cms:8055;
|
||||
proxy_pass $upstream_cms;
|
||||
proxy_http_version 1.1;
|
||||
client_max_body_size 100m;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# --- CMS (Directus) HTTPS ---
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name cms.irfartak.com;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/cms/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/cms/privkey.pem;
|
||||
|
||||
location / {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $upstream_cms_s http://cms:8055;
|
||||
proxy_pass $upstream_cms_s;
|
||||
proxy_http_version 1.1;
|
||||
client_max_body_size 100m;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
# certbot --deploy-hook: snap-confined Docker can't mount /etc/letsencrypt, so
|
||||
# copy the renewed cert into the project dir (mounted into nginx) and reload.
|
||||
set -e
|
||||
DEST=/root/statista/deploy/certs
|
||||
LIVE=/etc/letsencrypt/live/statista.steelforesight.ir
|
||||
mkdir -p "$DEST"
|
||||
cp -L "$LIVE/fullchain.pem" "$DEST/fullchain.pem"
|
||||
cp -L "$LIVE/privkey.pem" "$DEST/privkey.pem"
|
||||
chmod 644 "$DEST/fullchain.pem"
|
||||
chmod 600 "$DEST/privkey.pem"
|
||||
docker exec deploy-nginx-1 nginx -s reload
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Run the server stack (backend API + admin panel) on any Docker host.
|
||||
# docker compose up -d --build
|
||||
# Set secrets in a .env file next to this (ADMIN_PASSWORD, ASIANMETAL_TOKEN, ...).
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
image: didvan-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DB_PATH: /data/asianmetal.db
|
||||
REPORTS_DIR: /data/reports
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:5174}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-0}
|
||||
COOKIE_SAMESITE: ${COOKIE_SAMESITE:-lax}
|
||||
SEED_ADMIN: ${SEED_ADMIN:-0}
|
||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32,::1/128,172.16.0.0/12}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||
ASIANMETAL_TOKEN: ${ASIANMETAL_TOKEN:-}
|
||||
volumes:
|
||||
- didvan-data:/data
|
||||
# Chromium needs more shared memory than the 64MB default
|
||||
shm_size: "1gb"
|
||||
|
||||
# Admin panel — keep it OFF public networks. Uncomment to run it (local/VPN only).
|
||||
# admin:
|
||||
# build: { context: . , dockerfile: admin/Dockerfile }
|
||||
# image: didvan-admin
|
||||
# restart: unless-stopped
|
||||
# ports: ["127.0.0.1:8001:8001"]
|
||||
# environment:
|
||||
# DB_PATH: /data/asianmetal.db
|
||||
# ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
# ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||
# volumes: [ "didvan-data:/data" ]
|
||||
|
||||
volumes:
|
||||
didvan-data:
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
node_modules
|
||||
.output
|
||||
.vinxi
|
||||
.nitro
|
||||
.tanstack
|
||||
dist
|
||||
dist-ssr
|
||||
.env
|
||||
.env.*
|
||||
*.db
|
||||
*.sqlite*
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"template": "tanstack_start_ts_2026-05-29"
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
dist
|
||||
.output
|
||||
.vinxi
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
bun.lock
|
||||
routeTree.gen.ts
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Frontend (TanStack Start SSR) for self-hosted Docker deploy.
|
||||
# On Liara this folder is a Node buildpack and this Dockerfile is ignored;
|
||||
# it's only used by the VPS docker-compose.
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
# "/" -> API_BASE becomes "" (relative), so the browser calls /api on the same
|
||||
# origin and nginx proxies it to the backend. Avoids the Liara prod fallback URL.
|
||||
ENV VITE_API_BASE="/"
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim AS run
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
COPY --from=build /app/.output ./.output
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,6 @@
|
|||
[install]
|
||||
# 24h supply-chain guard: skip package versions published less than a day ago.
|
||||
minimumReleaseAge = 86400
|
||||
# Each entry bypasses the 24h guard for one package — confirm with the user
|
||||
# before adding any.
|
||||
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config"]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import js from "@eslint/js";
|
||||
import eslintPluginPrettier from "eslint-plugin-prettier/recommended";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", ".output", ".vinxi"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "server-only",
|
||||
message:
|
||||
"TanStack Start does not use the Next.js `server-only` package. Rename the module to `*.server.ts` or mark it with `@tanstack/react-start/server-only`.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
},
|
||||
},
|
||||
eslintPluginPrettier,
|
||||
);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"platform": "node",
|
||||
"app": "statista-didvan",
|
||||
"port": 3000,
|
||||
"build": {
|
||||
"location": "iran"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,100 @@
|
|||
{
|
||||
"name": "asianmetal-dashboard",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"preview": "vite preview",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@tanstack/react-router": "^1.168.25",
|
||||
"@tanstack/react-start": "^1.167.50",
|
||||
"@tanstack/router-plugin": "^1.167.28",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/three": "^0.184.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"flag-icons": "^7.5.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"gsap": "^3.15.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"lucide-react": "^0.575.0",
|
||||
"ogl": "^1.0.11",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-resizable-panels": "^4.6.5",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"three": "^0.167.1",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"vaul": "^1.1.2",
|
||||
"vite-tsconfig-paths": "^6.0.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@lovable.dev/vite-tanstack-config": "^2.1.1",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^15.15.0",
|
||||
"nitro": "3.0.260429-beta",
|
||||
"prettier": "^3.7.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#e9a93a"/>
|
||||
<stop offset="1" stop-color="#b9781a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#g)"/>
|
||||
<path d="M96 300 L188 300 L228 172 L292 360 L332 256 L360 300 L416 300"
|
||||
fill="none" stroke="#fff" stroke-width="30"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 538 B |
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "سامانه جامع آمار و اطلاعات دیدوان",
|
||||
"short_name": "دیدوان",
|
||||
"description": "بازار داخلی ارز، کامودیتی زنده و قیمت فلزات و فولاد",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"dir": "rtl",
|
||||
"lang": "fa",
|
||||
"background_color": "#f7f4ec",
|
||||
"theme_color": "#f7f4ec",
|
||||
"icons": [
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// Minimal service worker — enables PWA install + an offline fallback for
|
||||
// navigations. Live market data is never cached (always hits the network).
|
||||
const SHELL = "yz-shell-v1";
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
e.waitUntil(
|
||||
caches
|
||||
.open(SHELL)
|
||||
.then((c) => c.add("/"))
|
||||
.catch(() => {}),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {
|
||||
const req = e.request;
|
||||
// Only handle top-level navigations; let everything else (API, assets) pass through.
|
||||
if (req.mode !== "navigate") return;
|
||||
e.respondWith(
|
||||
fetch(req).catch(() => caches.match("/").then((r) => r || Response.error())),
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
.card-swap-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
transform: translate(5%, 20%);
|
||||
transform-origin: bottom right;
|
||||
|
||||
perspective: 900px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #fff;
|
||||
background: #000;
|
||||
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-swap-container {
|
||||
transform: scale(0.75) translate(25%, 25%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card-swap-container {
|
||||
transform: scale(0.55) translate(25%, 25%);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import React, {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import gsap from "gsap";
|
||||
import "./CardSwap.css";
|
||||
|
||||
export const Card = forwardRef(({ customClass, ...rest }, ref) => (
|
||||
<div ref={ref} {...rest} className={`card ${customClass ?? ""} ${rest.className ?? ""}`.trim()} />
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const makeSlot = (i, distX, distY, total) => ({
|
||||
x: i * distX,
|
||||
y: -i * distY,
|
||||
z: -i * distX * 1.5,
|
||||
zIndex: total - i,
|
||||
});
|
||||
const placeNow = (el, slot, skew) =>
|
||||
gsap.set(el, {
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
xPercent: -50,
|
||||
yPercent: -50,
|
||||
skewY: skew,
|
||||
transformOrigin: "center center",
|
||||
zIndex: slot.zIndex,
|
||||
force3D: true,
|
||||
});
|
||||
|
||||
const CardSwap = ({
|
||||
width = 500,
|
||||
height = 400,
|
||||
cardDistance = 60,
|
||||
verticalDistance = 70,
|
||||
delay = 5000,
|
||||
pauseOnHover = false,
|
||||
onCardClick,
|
||||
skewAmount = 6,
|
||||
easing = "elastic",
|
||||
children,
|
||||
}) => {
|
||||
const config =
|
||||
easing === "elastic"
|
||||
? {
|
||||
ease: "elastic.out(0.6,0.9)",
|
||||
durDrop: 2,
|
||||
durMove: 2,
|
||||
durReturn: 2,
|
||||
promoteOverlap: 0.9,
|
||||
returnDelay: 0.05,
|
||||
}
|
||||
: {
|
||||
ease: "power1.inOut",
|
||||
durDrop: 0.8,
|
||||
durMove: 0.8,
|
||||
durReturn: 0.8,
|
||||
promoteOverlap: 0.45,
|
||||
returnDelay: 0.2,
|
||||
};
|
||||
|
||||
const childArr = useMemo(() => Children.toArray(children), [children]);
|
||||
const refs = useMemo(
|
||||
() => childArr.map(() => React.createRef()),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[childArr.length],
|
||||
);
|
||||
|
||||
const order = useRef(Array.from({ length: childArr.length }, (_, i) => i));
|
||||
|
||||
const tlRef = useRef(null);
|
||||
const intervalRef = useRef();
|
||||
const container = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const total = refs.length;
|
||||
refs.forEach((r, i) =>
|
||||
placeNow(r.current, makeSlot(i, cardDistance, verticalDistance, total), skewAmount),
|
||||
);
|
||||
|
||||
const swap = () => {
|
||||
if (order.current.length < 2) return;
|
||||
|
||||
const [front, ...rest] = order.current;
|
||||
const elFront = refs[front].current;
|
||||
const tl = gsap.timeline();
|
||||
tlRef.current = tl;
|
||||
|
||||
tl.to(elFront, {
|
||||
y: "+=500",
|
||||
duration: config.durDrop,
|
||||
ease: config.ease,
|
||||
});
|
||||
|
||||
tl.addLabel("promote", `-=${config.durDrop * config.promoteOverlap}`);
|
||||
rest.forEach((idx, i) => {
|
||||
const el = refs[idx].current;
|
||||
const slot = makeSlot(i, cardDistance, verticalDistance, refs.length);
|
||||
tl.set(el, { zIndex: slot.zIndex }, "promote");
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
duration: config.durMove,
|
||||
ease: config.ease,
|
||||
},
|
||||
`promote+=${i * 0.15}`,
|
||||
);
|
||||
});
|
||||
|
||||
const backSlot = makeSlot(refs.length - 1, cardDistance, verticalDistance, refs.length);
|
||||
tl.addLabel("return", `promote+=${config.durMove * config.returnDelay}`);
|
||||
tl.call(
|
||||
() => {
|
||||
gsap.set(elFront, { zIndex: backSlot.zIndex });
|
||||
},
|
||||
undefined,
|
||||
"return",
|
||||
);
|
||||
tl.to(
|
||||
elFront,
|
||||
{
|
||||
x: backSlot.x,
|
||||
y: backSlot.y,
|
||||
z: backSlot.z,
|
||||
duration: config.durReturn,
|
||||
ease: config.ease,
|
||||
},
|
||||
"return",
|
||||
);
|
||||
|
||||
tl.call(() => {
|
||||
order.current = [...rest, front];
|
||||
});
|
||||
};
|
||||
|
||||
swap();
|
||||
intervalRef.current = window.setInterval(swap, delay);
|
||||
|
||||
if (pauseOnHover) {
|
||||
const node = container.current;
|
||||
const pause = () => {
|
||||
tlRef.current?.pause();
|
||||
clearInterval(intervalRef.current);
|
||||
};
|
||||
const resume = () => {
|
||||
tlRef.current?.play();
|
||||
intervalRef.current = window.setInterval(swap, delay);
|
||||
};
|
||||
node.addEventListener("mouseenter", pause);
|
||||
node.addEventListener("mouseleave", resume);
|
||||
return () => {
|
||||
node.removeEventListener("mouseenter", pause);
|
||||
node.removeEventListener("mouseleave", resume);
|
||||
clearInterval(intervalRef.current);
|
||||
};
|
||||
}
|
||||
return () => clearInterval(intervalRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cardDistance, verticalDistance, delay, pauseOnHover, skewAmount, easing]);
|
||||
|
||||
const rendered = childArr.map((child, i) =>
|
||||
isValidElement(child)
|
||||
? cloneElement(child, {
|
||||
key: i,
|
||||
ref: refs[i],
|
||||
style: { width, height, ...(child.props.style ?? {}) },
|
||||
onClick: (e) => {
|
||||
child.props.onClick?.(e);
|
||||
onCardClick?.(i);
|
||||
},
|
||||
})
|
||||
: child,
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={container} className="card-swap-container" style={{ width, height }}>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardSwap;
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
.circular-gallery {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.circular-gallery:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
|
@ -0,0 +1,591 @@
|
|||
import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from "ogl";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import "./CircularGallery.css";
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function (...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
function lerp(p1, p2, t) {
|
||||
return p1 + (p2 - p1) * t;
|
||||
}
|
||||
|
||||
function autoBind(instance) {
|
||||
const proto = Object.getPrototypeOf(instance);
|
||||
Object.getOwnPropertyNames(proto).forEach((key) => {
|
||||
if (key !== "constructor" && typeof instance[key] === "function") {
|
||||
instance[key] = instance[key].bind(instance);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_FONT = "bold 30px Figtree";
|
||||
const DEFAULT_FONT_URL =
|
||||
"https://fonts.googleapis.com/css2?family=Figtree:wght@400;700&display=swap";
|
||||
|
||||
function deriveFontFamilyFromUrl(url) {
|
||||
const fileName = (url.split("/").pop() || "custom-font").split("?")[0];
|
||||
const base = fileName.replace(/\.(woff2?|ttf|otf|eot)$/i, "");
|
||||
return base.replace(/[^a-zA-Z0-9-_ ]/g, "").trim() || "CircularGalleryFont";
|
||||
}
|
||||
|
||||
async function loadFontFromStylesheet(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to fetch font stylesheet (${response.status})`);
|
||||
const cssText = await response.text();
|
||||
const faceBlocks = cssText.match(/@font-face\s*{[^}]*}/g) || [];
|
||||
let family = null;
|
||||
const fontFaces = [];
|
||||
for (const block of faceBlocks) {
|
||||
const familyMatch = block.match(/font-family:\s*['"]?([^;'"]+)['"]?/);
|
||||
const urlMatch = block.match(/url\(\s*['"]?([^'")]+)['"]?\s*\)/);
|
||||
if (!familyMatch || !urlMatch) continue;
|
||||
family = familyMatch[1].trim();
|
||||
const descriptors = {};
|
||||
const weightMatch = block.match(/font-weight:\s*([^;]+);/);
|
||||
const styleMatch = block.match(/font-style:\s*([^;]+);/);
|
||||
const rangeMatch = block.match(/unicode-range:\s*([^;]+);/);
|
||||
if (weightMatch) descriptors.weight = weightMatch[1].trim();
|
||||
if (styleMatch) descriptors.style = styleMatch[1].trim();
|
||||
if (rangeMatch) descriptors.unicodeRange = rangeMatch[1].trim();
|
||||
fontFaces.push(new FontFace(family, `url(${urlMatch[1]})`, descriptors));
|
||||
}
|
||||
if (!family) throw new Error("No @font-face rule found in the stylesheet");
|
||||
await Promise.allSettled(
|
||||
fontFaces.map(async (face) => {
|
||||
await face.load();
|
||||
document.fonts.add(face);
|
||||
}),
|
||||
);
|
||||
return family;
|
||||
}
|
||||
|
||||
async function loadFontFromFile(url) {
|
||||
const family = deriveFontFamilyFromUrl(url);
|
||||
const fontFace = new FontFace(family, `url(${url})`);
|
||||
await fontFace.load();
|
||||
document.fonts.add(fontFace);
|
||||
return family;
|
||||
}
|
||||
|
||||
async function loadCustomFont(fontUrl) {
|
||||
const isStylesheet = fontUrl.includes("fonts.googleapis.com") || /\.css(\?.*)?$/i.test(fontUrl);
|
||||
return isStylesheet ? loadFontFromStylesheet(fontUrl) : loadFontFromFile(fontUrl);
|
||||
}
|
||||
|
||||
async function resolveFont(font, fontUrl) {
|
||||
const effectiveUrl = fontUrl || (font === DEFAULT_FONT ? DEFAULT_FONT_URL : null);
|
||||
if (!effectiveUrl) {
|
||||
if (document.fonts && document.fonts.load) {
|
||||
try {
|
||||
await document.fonts.load(font);
|
||||
await document.fonts.ready;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return font;
|
||||
}
|
||||
try {
|
||||
const family = await loadCustomFont(effectiveUrl);
|
||||
const sizeMatch = font.match(/^\s*(.*?\d+px)/);
|
||||
const prefix = sizeMatch ? sizeMatch[1].trim() : "bold 30px";
|
||||
const resolved = `${prefix} "${family}"`;
|
||||
if (document.fonts && document.fonts.load) {
|
||||
try {
|
||||
await document.fonts.load(resolved);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
} catch (error) {
|
||||
console.error("CircularGallery: unable to load font from", fontUrl, error);
|
||||
return font;
|
||||
}
|
||||
}
|
||||
|
||||
function getFontSize(font) {
|
||||
const match = font.match(/(\d+)px/);
|
||||
return match ? parseInt(match[1], 10) : 30;
|
||||
}
|
||||
|
||||
function createTextTexture(gl, text, font = "bold 30px monospace", color = "black") {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
context.font = font;
|
||||
const metrics = context.measureText(text);
|
||||
const textWidth = Math.ceil(metrics.width);
|
||||
const textHeight = Math.ceil(getFontSize(font) * 1.2);
|
||||
canvas.width = textWidth + 20;
|
||||
canvas.height = textHeight + 20;
|
||||
context.font = font;
|
||||
context.fillStyle = color;
|
||||
context.textBaseline = "middle";
|
||||
context.textAlign = "center";
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillText(text, canvas.width / 2, canvas.height / 2);
|
||||
const texture = new Texture(gl, { generateMipmaps: false });
|
||||
texture.image = canvas;
|
||||
return { texture, width: canvas.width, height: canvas.height };
|
||||
}
|
||||
|
||||
class Title {
|
||||
constructor({ gl, plane, renderer, text, textColor = "#545050", font = "30px sans-serif" }) {
|
||||
autoBind(this);
|
||||
this.gl = gl;
|
||||
this.plane = plane;
|
||||
this.renderer = renderer;
|
||||
this.text = text;
|
||||
this.textColor = textColor;
|
||||
this.font = font;
|
||||
this.createMesh();
|
||||
}
|
||||
createMesh() {
|
||||
const { texture, width, height } = createTextTexture(
|
||||
this.gl,
|
||||
this.text,
|
||||
this.font,
|
||||
this.textColor,
|
||||
);
|
||||
const geometry = new Plane(this.gl);
|
||||
const program = new Program(this.gl, {
|
||||
vertex: `
|
||||
attribute vec3 position;
|
||||
attribute vec2 uv;
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragment: `
|
||||
precision highp float;
|
||||
uniform sampler2D tMap;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vec4 color = texture2D(tMap, vUv);
|
||||
if (color.a < 0.1) discard;
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`,
|
||||
uniforms: { tMap: { value: texture } },
|
||||
transparent: true,
|
||||
});
|
||||
this.mesh = new Mesh(this.gl, { geometry, program });
|
||||
const aspect = width / height;
|
||||
const textHeight = this.plane.scale.y * 0.15;
|
||||
const textWidth = textHeight * aspect;
|
||||
this.mesh.scale.set(textWidth, textHeight, 1);
|
||||
this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeight * 0.5 - 0.05;
|
||||
this.mesh.setParent(this.plane);
|
||||
}
|
||||
}
|
||||
|
||||
class Media {
|
||||
constructor({
|
||||
geometry,
|
||||
gl,
|
||||
image,
|
||||
index,
|
||||
length,
|
||||
renderer,
|
||||
scene,
|
||||
screen,
|
||||
text,
|
||||
viewport,
|
||||
bend,
|
||||
textColor,
|
||||
borderRadius = 0,
|
||||
font,
|
||||
}) {
|
||||
this.extra = 0;
|
||||
this.geometry = geometry;
|
||||
this.gl = gl;
|
||||
this.image = image;
|
||||
this.index = index;
|
||||
this.length = length;
|
||||
this.renderer = renderer;
|
||||
this.scene = scene;
|
||||
this.screen = screen;
|
||||
this.text = text;
|
||||
this.viewport = viewport;
|
||||
this.bend = bend;
|
||||
this.textColor = textColor;
|
||||
this.borderRadius = borderRadius;
|
||||
this.font = font;
|
||||
this.createShader();
|
||||
this.createMesh();
|
||||
this.createTitle();
|
||||
this.onResize();
|
||||
}
|
||||
createShader() {
|
||||
const texture = new Texture(this.gl, {
|
||||
generateMipmaps: true,
|
||||
});
|
||||
this.program = new Program(this.gl, {
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
vertex: `
|
||||
precision highp float;
|
||||
attribute vec3 position;
|
||||
attribute vec2 uv;
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform float uTime;
|
||||
uniform float uSpeed;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vec3 p = position;
|
||||
p.z = 0.0; // flat — no wave/ripple, cards stand still
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
||||
}
|
||||
`,
|
||||
fragment: `
|
||||
precision highp float;
|
||||
uniform vec2 uImageSizes;
|
||||
uniform vec2 uPlaneSizes;
|
||||
uniform sampler2D tMap;
|
||||
uniform float uBorderRadius;
|
||||
varying vec2 vUv;
|
||||
|
||||
float roundedBoxSDF(vec2 p, vec2 b, float r) {
|
||||
vec2 d = abs(p) - b;
|
||||
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 ratio = vec2(
|
||||
min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0),
|
||||
min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0)
|
||||
);
|
||||
vec2 uv = vec2(
|
||||
vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
|
||||
vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
|
||||
);
|
||||
vec4 color = texture2D(tMap, uv);
|
||||
|
||||
float d = roundedBoxSDF(vUv - 0.5, vec2(0.5 - uBorderRadius), uBorderRadius);
|
||||
|
||||
float edgeSmooth = 0.002;
|
||||
float alpha = 1.0 - smoothstep(-edgeSmooth, edgeSmooth, d);
|
||||
|
||||
gl_FragColor = vec4(color.rgb, alpha);
|
||||
}
|
||||
`,
|
||||
uniforms: {
|
||||
tMap: { value: texture },
|
||||
uPlaneSizes: { value: [0, 0] },
|
||||
uImageSizes: { value: [0, 0] },
|
||||
uSpeed: { value: 0 },
|
||||
uTime: { value: 100 * Math.random() },
|
||||
uBorderRadius: { value: this.borderRadius },
|
||||
},
|
||||
transparent: true,
|
||||
});
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = this.image;
|
||||
img.onload = () => {
|
||||
texture.image = img;
|
||||
this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight];
|
||||
};
|
||||
}
|
||||
createMesh() {
|
||||
this.plane = new Mesh(this.gl, {
|
||||
geometry: this.geometry,
|
||||
program: this.program,
|
||||
});
|
||||
this.plane.setParent(this.scene);
|
||||
}
|
||||
createTitle() {
|
||||
this.title = new Title({
|
||||
gl: this.gl,
|
||||
plane: this.plane,
|
||||
renderer: this.renderer,
|
||||
text: this.text,
|
||||
textColor: this.textColor,
|
||||
font: this.font,
|
||||
});
|
||||
}
|
||||
update(scroll, direction) {
|
||||
this.plane.position.x = this.x - scroll.current - this.extra;
|
||||
|
||||
const x = this.plane.position.x;
|
||||
const H = this.viewport.width / 2;
|
||||
|
||||
if (this.bend === 0) {
|
||||
this.plane.position.y = 0;
|
||||
this.plane.rotation.z = 0;
|
||||
} else {
|
||||
const B_abs = Math.abs(this.bend);
|
||||
const R = (H * H + B_abs * B_abs) / (2 * B_abs);
|
||||
const effectiveX = Math.min(Math.abs(x), H);
|
||||
|
||||
const arc = R - Math.sqrt(R * R - effectiveX * effectiveX);
|
||||
if (this.bend > 0) {
|
||||
this.plane.position.y = -arc;
|
||||
this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R);
|
||||
} else {
|
||||
this.plane.position.y = arc;
|
||||
this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R);
|
||||
}
|
||||
}
|
||||
|
||||
this.speed = scroll.current - scroll.last;
|
||||
this.program.uniforms.uTime.value += 0.04;
|
||||
this.program.uniforms.uSpeed.value = this.speed;
|
||||
|
||||
const planeOffset = this.plane.scale.x / 2;
|
||||
const viewportOffset = this.viewport.width / 2;
|
||||
this.isBefore = this.plane.position.x + planeOffset < -viewportOffset;
|
||||
this.isAfter = this.plane.position.x - planeOffset > viewportOffset;
|
||||
if (direction === "right" && this.isBefore) {
|
||||
this.extra -= this.widthTotal;
|
||||
this.isBefore = this.isAfter = false;
|
||||
}
|
||||
if (direction === "left" && this.isAfter) {
|
||||
this.extra += this.widthTotal;
|
||||
this.isBefore = this.isAfter = false;
|
||||
}
|
||||
}
|
||||
onResize({ screen, viewport } = {}) {
|
||||
if (screen) this.screen = screen;
|
||||
if (viewport) {
|
||||
this.viewport = viewport;
|
||||
if (this.plane.program.uniforms.uViewportSizes) {
|
||||
this.plane.program.uniforms.uViewportSizes.value = [
|
||||
this.viewport.width,
|
||||
this.viewport.height,
|
||||
];
|
||||
}
|
||||
}
|
||||
this.scale = this.screen.height / 1500;
|
||||
this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height;
|
||||
this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width;
|
||||
this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y];
|
||||
this.padding = 2;
|
||||
this.width = this.plane.scale.x + this.padding;
|
||||
this.widthTotal = this.width * this.length;
|
||||
this.x = this.width * this.index;
|
||||
}
|
||||
}
|
||||
|
||||
class App {
|
||||
constructor(
|
||||
container,
|
||||
{
|
||||
items,
|
||||
bend,
|
||||
textColor = "#ffffff",
|
||||
borderRadius = 0,
|
||||
font = "bold 30px Figtree",
|
||||
scrollSpeed = 2,
|
||||
scrollEase = 0.05,
|
||||
} = {},
|
||||
) {
|
||||
document.documentElement.classList.remove("no-js");
|
||||
this.container = container;
|
||||
this.scrollSpeed = scrollSpeed;
|
||||
this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 };
|
||||
this.onCheckDebounce = debounce(this.onCheck, 200);
|
||||
this.createRenderer();
|
||||
this.createCamera();
|
||||
this.createScene();
|
||||
this.onResize();
|
||||
this.createGeometry();
|
||||
this.createMedias(items, bend, textColor, borderRadius, font);
|
||||
this.update();
|
||||
this.addEventListeners();
|
||||
}
|
||||
createRenderer() {
|
||||
this.renderer = new Renderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
dpr: Math.min(window.devicePixelRatio || 1, 2),
|
||||
});
|
||||
this.gl = this.renderer.gl;
|
||||
this.gl.clearColor(0, 0, 0, 0);
|
||||
this.container.appendChild(this.gl.canvas);
|
||||
}
|
||||
createCamera() {
|
||||
this.camera = new Camera(this.gl);
|
||||
this.camera.fov = 45;
|
||||
this.camera.position.z = 20;
|
||||
}
|
||||
createScene() {
|
||||
this.scene = new Transform();
|
||||
}
|
||||
createGeometry() {
|
||||
this.planeGeometry = new Plane(this.gl, {
|
||||
heightSegments: 50,
|
||||
widthSegments: 100,
|
||||
});
|
||||
}
|
||||
createMedias(items, bend = 1, textColor, borderRadius, font) {
|
||||
const defaultItems = [
|
||||
{ image: `https://picsum.photos/seed/1/800/600?grayscale`, text: "Bridge" },
|
||||
{ image: `https://picsum.photos/seed/2/800/600?grayscale`, text: "Desk Setup" },
|
||||
{ image: `https://picsum.photos/seed/3/800/600?grayscale`, text: "Waterfall" },
|
||||
{ image: `https://picsum.photos/seed/4/800/600?grayscale`, text: "Strawberries" },
|
||||
{ image: `https://picsum.photos/seed/5/800/600?grayscale`, text: "Deep Diving" },
|
||||
{ image: `https://picsum.photos/seed/16/800/600?grayscale`, text: "Train Track" },
|
||||
{ image: `https://picsum.photos/seed/17/800/600?grayscale`, text: "Santorini" },
|
||||
{ image: `https://picsum.photos/seed/8/800/600?grayscale`, text: "Blurry Lights" },
|
||||
{ image: `https://picsum.photos/seed/9/800/600?grayscale`, text: "New York" },
|
||||
{ image: `https://picsum.photos/seed/10/800/600?grayscale`, text: "Good Boy" },
|
||||
{ image: `https://picsum.photos/seed/21/800/600?grayscale`, text: "Coastline" },
|
||||
{ image: `https://picsum.photos/seed/12/800/600?grayscale`, text: "Palm Trees" },
|
||||
];
|
||||
const galleryItems = items && items.length ? items : defaultItems;
|
||||
this.mediasImages = galleryItems.concat(galleryItems);
|
||||
this.medias = this.mediasImages.map((data, index) => {
|
||||
return new Media({
|
||||
geometry: this.planeGeometry,
|
||||
gl: this.gl,
|
||||
image: data.image,
|
||||
index,
|
||||
length: this.mediasImages.length,
|
||||
renderer: this.renderer,
|
||||
scene: this.scene,
|
||||
screen: this.screen,
|
||||
text: data.text,
|
||||
viewport: this.viewport,
|
||||
bend,
|
||||
textColor,
|
||||
borderRadius,
|
||||
font,
|
||||
});
|
||||
});
|
||||
}
|
||||
onTouchDown(e) {
|
||||
this.isDown = true;
|
||||
this.scroll.position = this.scroll.current;
|
||||
this.start = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
}
|
||||
onTouchMove(e) {
|
||||
if (!this.isDown) return;
|
||||
const x = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const distance = (this.start - x) * (this.scrollSpeed * 0.025);
|
||||
this.scroll.target = this.scroll.position + distance;
|
||||
}
|
||||
onTouchUp() {
|
||||
this.isDown = false;
|
||||
this.onCheck();
|
||||
}
|
||||
onWheel(e) {
|
||||
const delta = e.deltaY || e.wheelDelta || e.detail;
|
||||
this.scroll.target += (delta > 0 ? this.scrollSpeed : -this.scrollSpeed) * 0.2;
|
||||
this.onCheckDebounce();
|
||||
}
|
||||
onCheck() {
|
||||
if (!this.medias || !this.medias[0]) return;
|
||||
const width = this.medias[0].width;
|
||||
const itemIndex = Math.round(Math.abs(this.scroll.target) / width);
|
||||
const item = width * itemIndex;
|
||||
this.scroll.target = this.scroll.target < 0 ? -item : item;
|
||||
}
|
||||
onResize() {
|
||||
this.screen = {
|
||||
width: this.container.clientWidth,
|
||||
height: this.container.clientHeight,
|
||||
};
|
||||
this.renderer.setSize(this.screen.width, this.screen.height);
|
||||
this.camera.perspective({
|
||||
aspect: this.screen.width / this.screen.height,
|
||||
});
|
||||
const fov = (this.camera.fov * Math.PI) / 180;
|
||||
const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
|
||||
const width = height * this.camera.aspect;
|
||||
this.viewport = { width, height };
|
||||
if (this.medias) {
|
||||
this.medias.forEach((media) =>
|
||||
media.onResize({ screen: this.screen, viewport: this.viewport }),
|
||||
);
|
||||
}
|
||||
}
|
||||
update() {
|
||||
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease);
|
||||
const direction = this.scroll.current > this.scroll.last ? "right" : "left";
|
||||
if (this.medias) {
|
||||
this.medias.forEach((media) => media.update(this.scroll, direction));
|
||||
}
|
||||
this.renderer.render({ scene: this.scene, camera: this.camera });
|
||||
this.scroll.last = this.scroll.current;
|
||||
this.raf = window.requestAnimationFrame(this.update.bind(this));
|
||||
}
|
||||
addEventListeners() {
|
||||
this.boundOnResize = this.onResize.bind(this);
|
||||
this.boundOnWheel = this.onWheel.bind(this);
|
||||
this.boundOnTouchDown = this.onTouchDown.bind(this);
|
||||
this.boundOnTouchMove = this.onTouchMove.bind(this);
|
||||
this.boundOnTouchUp = this.onTouchUp.bind(this);
|
||||
window.addEventListener("resize", this.boundOnResize);
|
||||
window.addEventListener("mousewheel", this.boundOnWheel);
|
||||
window.addEventListener("wheel", this.boundOnWheel);
|
||||
window.addEventListener("mousedown", this.boundOnTouchDown);
|
||||
window.addEventListener("mousemove", this.boundOnTouchMove);
|
||||
window.addEventListener("mouseup", this.boundOnTouchUp);
|
||||
window.addEventListener("touchstart", this.boundOnTouchDown);
|
||||
window.addEventListener("touchmove", this.boundOnTouchMove);
|
||||
window.addEventListener("touchend", this.boundOnTouchUp);
|
||||
}
|
||||
destroy() {
|
||||
window.cancelAnimationFrame(this.raf);
|
||||
window.removeEventListener("resize", this.boundOnResize);
|
||||
window.removeEventListener("mousewheel", this.boundOnWheel);
|
||||
window.removeEventListener("wheel", this.boundOnWheel);
|
||||
window.removeEventListener("mousedown", this.boundOnTouchDown);
|
||||
window.removeEventListener("mousemove", this.boundOnTouchMove);
|
||||
window.removeEventListener("mouseup", this.boundOnTouchUp);
|
||||
window.removeEventListener("touchstart", this.boundOnTouchDown);
|
||||
window.removeEventListener("touchmove", this.boundOnTouchMove);
|
||||
window.removeEventListener("touchend", this.boundOnTouchUp);
|
||||
if (this.renderer && this.renderer.gl && this.renderer.gl.canvas.parentNode) {
|
||||
this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function CircularGallery({
|
||||
items,
|
||||
bend = 3,
|
||||
textColor = "#ffffff",
|
||||
borderRadius = 0.05,
|
||||
font = "bold 30px Figtree",
|
||||
fontUrl,
|
||||
scrollSpeed = 2,
|
||||
scrollEase = 0.05,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
let app;
|
||||
let isMounted = true;
|
||||
resolveFont(font, fontUrl).then((resolvedFont) => {
|
||||
if (!isMounted || !containerRef.current) return;
|
||||
app = new App(containerRef.current, {
|
||||
items,
|
||||
bend,
|
||||
textColor,
|
||||
borderRadius,
|
||||
font: resolvedFont,
|
||||
scrollSpeed,
|
||||
scrollEase,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (app) app.destroy();
|
||||
};
|
||||
}, [items, bend, textColor, borderRadius, font, fontUrl, scrollSpeed, scrollEase]);
|
||||
return <div className="circular-gallery" ref={containerRef} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.laser-flow-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
@ -0,0 +1,584 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import './LaserFlow.css';
|
||||
|
||||
const VERT = `
|
||||
precision highp float;
|
||||
attribute vec3 position;
|
||||
void main(){
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG = `
|
||||
#ifdef GL_ES
|
||||
#extension GL_OES_standard_derivatives : enable
|
||||
#endif
|
||||
precision highp float;
|
||||
precision mediump int;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform vec4 iMouse;
|
||||
uniform float uWispDensity;
|
||||
uniform float uTiltScale;
|
||||
uniform float uFlowTime;
|
||||
uniform float uFogTime;
|
||||
uniform float uBeamXFrac;
|
||||
uniform float uBeamYFrac;
|
||||
uniform float uFlowSpeed;
|
||||
uniform float uVLenFactor;
|
||||
uniform float uHLenFactor;
|
||||
uniform float uFogIntensity;
|
||||
uniform float uFogScale;
|
||||
uniform float uWSpeed;
|
||||
uniform float uWIntensity;
|
||||
uniform float uFlowStrength;
|
||||
uniform float uDecay;
|
||||
uniform float uFalloffStart;
|
||||
uniform float uFogFallSpeed;
|
||||
uniform vec3 uColor;
|
||||
uniform float uFade;
|
||||
|
||||
// Core beam/flare shaping and dynamics
|
||||
#define PI 3.14159265359
|
||||
#define TWO_PI 6.28318530718
|
||||
#define EPS 1e-6
|
||||
#define EDGE_SOFT (DT_LOCAL*4.0)
|
||||
#define DT_LOCAL 0.0038
|
||||
#define TAP_RADIUS 6
|
||||
#define R_H 150.0
|
||||
#define R_V 150.0
|
||||
#define FLARE_HEIGHT 16.0
|
||||
#define FLARE_AMOUNT 8.0
|
||||
#define FLARE_EXP 2.0
|
||||
#define TOP_FADE_START 0.1
|
||||
#define TOP_FADE_EXP 1.0
|
||||
#define FLOW_PERIOD 0.5
|
||||
#define FLOW_SHARPNESS 1.5
|
||||
|
||||
// Wisps (animated micro-streaks) that travel along the beam
|
||||
#define W_BASE_X 1.5
|
||||
#define W_LAYER_GAP 0.25
|
||||
#define W_LANES 10
|
||||
#define W_SIDE_DECAY 0.5
|
||||
#define W_HALF 0.01
|
||||
#define W_AA 0.15
|
||||
#define W_CELL 20.0
|
||||
#define W_SEG_MIN 0.01
|
||||
#define W_SEG_MAX 0.55
|
||||
#define W_CURVE_AMOUNT 15.0
|
||||
#define W_CURVE_RANGE (FLARE_HEIGHT - 3.0)
|
||||
#define W_BOTTOM_EXP 10.0
|
||||
|
||||
// Volumetric fog controls
|
||||
#define FOG_ON 1
|
||||
#define FOG_CONTRAST 1.2
|
||||
#define FOG_SPEED_U 0.1
|
||||
#define FOG_SPEED_V -0.1
|
||||
#define FOG_OCTAVES 5
|
||||
#define FOG_BOTTOM_BIAS 0.8
|
||||
#define FOG_TILT_TO_MOUSE 0.05
|
||||
#define FOG_TILT_DEADZONE 0.01
|
||||
#define FOG_TILT_MAX_X 0.35
|
||||
#define FOG_TILT_SHAPE 1.5
|
||||
#define FOG_BEAM_MIN 0.0
|
||||
#define FOG_BEAM_MAX 0.75
|
||||
#define FOG_MASK_GAMMA 0.5
|
||||
#define FOG_EXPAND_SHAPE 12.2
|
||||
#define FOG_EDGE_MIX 0.5
|
||||
|
||||
// Horizontal vignette for the fog volume
|
||||
#define HFOG_EDGE_START 0.20
|
||||
#define HFOG_EDGE_END 0.98
|
||||
#define HFOG_EDGE_GAMMA 1.4
|
||||
#define HFOG_Y_RADIUS 25.0
|
||||
#define HFOG_Y_SOFT 60.0
|
||||
|
||||
// Beam extents and edge masking
|
||||
#define EDGE_X0 0.22
|
||||
#define EDGE_X1 0.995
|
||||
#define EDGE_X_GAMMA 1.25
|
||||
#define EDGE_LUMA_T0 0.0
|
||||
#define EDGE_LUMA_T1 2.0
|
||||
#define DITHER_STRENGTH 1.0
|
||||
|
||||
float g(float x){return x<=0.00031308?12.92*x:1.055*pow(x,1.0/2.4)-0.055;}
|
||||
float bs(vec2 p,vec2 q,float powr){
|
||||
float d=distance(p,q),f=powr*uFalloffStart,r=(f*f)/(d*d+EPS);
|
||||
return powr*min(1.0,r);
|
||||
}
|
||||
float bsa(vec2 p,vec2 q,float powr,vec2 s){
|
||||
vec2 d=p-q; float dd=(d.x*d.x)/(s.x*s.x)+(d.y*d.y)/(s.y*s.y),f=powr*uFalloffStart,r=(f*f)/(dd+EPS);
|
||||
return powr*min(1.0,r);
|
||||
}
|
||||
float tri01(float x){float f=fract(x);return 1.0-abs(f*2.0-1.0);}
|
||||
float tauWf(float t,float tmin,float tmax){float a=smoothstep(tmin,tmin+EDGE_SOFT,t),b=1.0-smoothstep(tmax-EDGE_SOFT,tmax,t);return max(0.0,a*b);}
|
||||
float h21(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+34.123);return fract(p.x*p.y);}
|
||||
float vnoise(vec2 p){
|
||||
vec2 i=floor(p),f=fract(p);
|
||||
float a=h21(i),b=h21(i+vec2(1,0)),c=h21(i+vec2(0,1)),d=h21(i+vec2(1,1));
|
||||
vec2 u=f*f*(3.0-2.0*f);
|
||||
return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);
|
||||
}
|
||||
float fbm2(vec2 p){
|
||||
float v=0.0,amp=0.6; mat2 m=mat2(0.86,0.5,-0.5,0.86);
|
||||
for(int i=0;i<FOG_OCTAVES;++i){v+=amp*vnoise(p); p=m*p*2.03+17.1; amp*=0.52;}
|
||||
return v;
|
||||
}
|
||||
float rGate(float x,float l){float a=smoothstep(0.0,W_AA,x),b=1.0-smoothstep(l,l+W_AA,x);return max(0.0,a*b);}
|
||||
float flareY(float y){float t=clamp(1.0-(clamp(y,0.0,FLARE_HEIGHT)/max(FLARE_HEIGHT,EPS)),0.0,1.0);return pow(t,FLARE_EXP);}
|
||||
|
||||
float vWisps(vec2 uv,float topF){
|
||||
float y=uv.y,yf=(y+uFlowTime*uWSpeed)/W_CELL;
|
||||
float dRaw=clamp(uWispDensity,0.0,2.0),d=dRaw<=0.0?1.0:dRaw;
|
||||
float lanesF=floor(float(W_LANES)*min(d,1.0)+0.5); // WebGL1-safe
|
||||
int lanes=int(max(1.0,lanesF));
|
||||
float sp=min(d,1.0),ep=max(d-1.0,0.0);
|
||||
float fm=flareY(max(y,0.0)),rm=clamp(1.0-(y/max(W_CURVE_RANGE,EPS)),0.0,1.0),cm=fm*rm;
|
||||
const float G=0.05; float xS=1.0+(FLARE_AMOUNT*W_CURVE_AMOUNT*G)*cm;
|
||||
float sPix=clamp(y/R_V,0.0,1.0),bGain=pow(1.0-sPix,W_BOTTOM_EXP),sum=0.0;
|
||||
for(int s=0;s<2;++s){
|
||||
float sgn=s==0?-1.0:1.0;
|
||||
for(int i=0;i<W_LANES;++i){
|
||||
if(i>=lanes) break;
|
||||
float off=W_BASE_X+float(i)*W_LAYER_GAP,xc=sgn*(off*xS);
|
||||
float dx=abs(uv.x-xc),lat=1.0-smoothstep(W_HALF,W_HALF+W_AA,dx),amp=exp(-off*W_SIDE_DECAY);
|
||||
float seed=h21(vec2(off,sgn*17.0)),yf2=yf+seed*7.0,ci=floor(yf2),fy=fract(yf2);
|
||||
float seg=mix(W_SEG_MIN,W_SEG_MAX,h21(vec2(ci,off*2.3)));
|
||||
float spR=h21(vec2(ci,off+sgn*31.0)),seg1=rGate(fy,seg)*step(spR,sp);
|
||||
if(ep>0.0){float spR2=h21(vec2(ci*3.1+7.0,off*5.3+sgn*13.0)); float f2=fract(fy+0.5); seg1+=rGate(f2,seg*0.9)*step(spR2,ep);}
|
||||
sum+=amp*lat*seg1;
|
||||
}
|
||||
}
|
||||
float span=smoothstep(-3.0,0.0,y)*(1.0-smoothstep(R_V-6.0,R_V,y));
|
||||
return uWIntensity*sum*topF*bGain*span;
|
||||
}
|
||||
|
||||
void mainImage(out vec4 fc,in vec2 frag){
|
||||
vec2 C=iResolution.xy*.5; float invW=1.0/max(C.x,1.0);
|
||||
vec2 sc=(512.0/iResolution.xy)*.4;
|
||||
vec2 uv=(frag-C)*sc,off=vec2(uBeamXFrac*iResolution.x*sc.x,uBeamYFrac*iResolution.y*sc.y);
|
||||
vec2 uvc = uv - off;
|
||||
float a=0.0,b=0.0;
|
||||
float basePhase=1.5*PI+uDecay*.5; float tauMin=basePhase-uDecay; float tauMax=basePhase;
|
||||
float cx=clamp(uvc.x/(R_H*uHLenFactor),-1.0,1.0),tH=clamp(TWO_PI-acos(cx),tauMin,tauMax);
|
||||
for(int k=-TAP_RADIUS;k<=TAP_RADIUS;++k){
|
||||
float tu=tH+float(k)*DT_LOCAL,wt=tauWf(tu,tauMin,tauMax); if(wt<=0.0) continue;
|
||||
float spd=max(abs(sin(tu)),0.02),u=clamp((basePhase-tu)/max(uDecay,EPS),0.0,1.0),env=pow(1.0-abs(u*2.0-1.0),0.8);
|
||||
vec2 p=vec2((R_H*uHLenFactor)*cos(tu),0.0);
|
||||
a+=wt*bs(uvc,p,env*spd);
|
||||
}
|
||||
float yPix=uvc.y,cy=clamp(-yPix/(R_V*uVLenFactor),-1.0,1.0),tV=clamp(TWO_PI-acos(cy),tauMin,tauMax);
|
||||
for(int k=-TAP_RADIUS;k<=TAP_RADIUS;++k){
|
||||
float tu=tV+float(k)*DT_LOCAL,wt=tauWf(tu,tauMin,tauMax); if(wt<=0.0) continue;
|
||||
float yb=(-R_V)*cos(tu),s=clamp(yb/R_V,0.0,1.0),spd=max(abs(sin(tu)),0.02);
|
||||
float env=pow(1.0-s,0.6)*spd;
|
||||
float cap=1.0-smoothstep(TOP_FADE_START,1.0,s); cap=pow(cap,TOP_FADE_EXP); env*=cap;
|
||||
float ph=s/max(FLOW_PERIOD,EPS)+uFlowTime*uFlowSpeed;
|
||||
float fl=pow(tri01(ph),FLOW_SHARPNESS);
|
||||
env*=mix(1.0-uFlowStrength,1.0,fl);
|
||||
float yp=(-R_V*uVLenFactor)*cos(tu),m=pow(smoothstep(FLARE_HEIGHT,0.0,yp),FLARE_EXP),wx=1.0+FLARE_AMOUNT*m;
|
||||
vec2 sig=vec2(wx,1.0),p=vec2(0.0,yp);
|
||||
float mask=step(0.0,yp);
|
||||
b+=wt*bsa(uvc,p,mask*env,sig);
|
||||
}
|
||||
float sPix=clamp(yPix/R_V,0.0,1.0),topA=pow(1.0-smoothstep(TOP_FADE_START,1.0,sPix),TOP_FADE_EXP);
|
||||
float L=a+b*topA;
|
||||
float w=vWisps(vec2(uvc.x,yPix),topA);
|
||||
float fog=0.0;
|
||||
#if FOG_ON
|
||||
vec2 fuv=uvc*uFogScale;
|
||||
float mAct=step(1.0,length(iMouse.xy)),nx=((iMouse.x-C.x)*invW)*mAct;
|
||||
float ax = abs(nx);
|
||||
float stMag = mix(ax, pow(ax, FOG_TILT_SHAPE), 0.35);
|
||||
float st = sign(nx) * stMag * uTiltScale;
|
||||
st = clamp(st, -FOG_TILT_MAX_X, FOG_TILT_MAX_X);
|
||||
vec2 dir=normalize(vec2(st,1.0));
|
||||
fuv+=uFogTime*uFogFallSpeed*dir;
|
||||
vec2 prp=vec2(-dir.y,dir.x);
|
||||
fuv+=prp*(0.08*sin(dot(uvc,prp)*0.08+uFogTime*0.9));
|
||||
float n=fbm2(fuv+vec2(fbm2(fuv+vec2(7.3,2.1)),fbm2(fuv+vec2(-3.7,5.9)))*0.6);
|
||||
n=pow(clamp(n,0.0,1.0),FOG_CONTRAST);
|
||||
float pixW = 1.0 / max(iResolution.y, 1.0);
|
||||
#ifdef GL_OES_standard_derivatives
|
||||
float wL = max(fwidth(L), pixW);
|
||||
#else
|
||||
float wL = pixW;
|
||||
#endif
|
||||
float m0=pow(smoothstep(FOG_BEAM_MIN - wL, FOG_BEAM_MAX + wL, L),FOG_MASK_GAMMA);
|
||||
float bm=1.0-pow(1.0-m0,FOG_EXPAND_SHAPE); bm=mix(bm*m0,bm,FOG_EDGE_MIX);
|
||||
float yP=1.0-smoothstep(HFOG_Y_RADIUS,HFOG_Y_RADIUS+HFOG_Y_SOFT,abs(yPix));
|
||||
float nxF=abs((frag.x-C.x)*invW),hE=1.0-smoothstep(HFOG_EDGE_START,HFOG_EDGE_END,nxF); hE=pow(clamp(hE,0.0,1.0),HFOG_EDGE_GAMMA);
|
||||
float hW=mix(1.0,hE,clamp(yP,0.0,1.0));
|
||||
float bBias=mix(1.0,1.0-sPix,FOG_BOTTOM_BIAS);
|
||||
float browserFogIntensity = uFogIntensity;
|
||||
browserFogIntensity *= 1.8;
|
||||
float radialFade = 1.0 - smoothstep(0.0, 0.7, length(uvc) / 120.0);
|
||||
float safariFog = n * browserFogIntensity * bBias * bm * hW * radialFade;
|
||||
fog = safariFog;
|
||||
#endif
|
||||
float LF=L+fog;
|
||||
float dith=(h21(frag)-0.5)*(DITHER_STRENGTH/255.0);
|
||||
float tone=g(LF+w);
|
||||
vec3 col=tone*uColor+dith;
|
||||
float alpha=clamp(g(L+w*0.6)+dith*0.6,0.0,1.0);
|
||||
float nxE=abs((frag.x-C.x)*invW),xF=pow(clamp(1.0-smoothstep(EDGE_X0,EDGE_X1,nxE),0.0,1.0),EDGE_X_GAMMA);
|
||||
float scene=LF+max(0.0,w)*0.5,hi=smoothstep(EDGE_LUMA_T0,EDGE_LUMA_T1,scene);
|
||||
float eM=mix(xF,1.0,hi);
|
||||
col*=eM; alpha*=eM;
|
||||
col*=uFade; alpha*=uFade;
|
||||
fc=vec4(col,alpha);
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec4 fc;
|
||||
mainImage(fc, gl_FragCoord.xy);
|
||||
gl_FragColor = fc;
|
||||
}
|
||||
`;
|
||||
|
||||
export const LaserFlow = ({
|
||||
className,
|
||||
style,
|
||||
wispDensity = 1,
|
||||
dpr,
|
||||
mouseSmoothTime = 0.0,
|
||||
mouseTiltStrength = 0.01,
|
||||
horizontalBeamOffset = 0.1,
|
||||
verticalBeamOffset = 0.0,
|
||||
flowSpeed = 0.35,
|
||||
verticalSizing = 2.0,
|
||||
horizontalSizing = 0.5,
|
||||
fogIntensity = 0.45,
|
||||
fogScale = 0.3,
|
||||
wispSpeed = 15.0,
|
||||
wispIntensity = 5.0,
|
||||
flowStrength = 0.25,
|
||||
decay = 1.1,
|
||||
falloffStart = 1.2,
|
||||
fogFallSpeed = 0.6,
|
||||
color = '#FF79C6'
|
||||
}) => {
|
||||
const mountRef = useRef(null);
|
||||
const rendererRef = useRef(null);
|
||||
const uniformsRef = useRef(null);
|
||||
const hasFadedRef = useRef(false);
|
||||
const rectRef = useRef(null);
|
||||
const baseDprRef = useRef(1);
|
||||
const currentDprRef = useRef(1);
|
||||
const lastSizeRef = useRef({ width: 0, height: 0, dpr: 0 });
|
||||
const fpsSamplesRef = useRef([]);
|
||||
const lastFpsCheckRef = useRef(performance.now());
|
||||
const emaDtRef = useRef(16.7);
|
||||
const pausedRef = useRef(false);
|
||||
const inViewRef = useRef(true);
|
||||
|
||||
const hexToRGB = hex => {
|
||||
let c = hex.trim();
|
||||
if (c[0] === '#') c = c.slice(1);
|
||||
if (c.length === 3)
|
||||
c = c
|
||||
.split('')
|
||||
.map(x => x + x)
|
||||
.join('');
|
||||
const n = parseInt(c, 16) || 0xffffff;
|
||||
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: false,
|
||||
alpha: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
powerPreference: 'high-performance',
|
||||
premultipliedAlpha: false,
|
||||
preserveDrawingBuffer: false,
|
||||
failIfMajorPerformanceCaveat: false,
|
||||
logarithmicDepthBuffer: false
|
||||
});
|
||||
rendererRef.current = renderer;
|
||||
|
||||
baseDprRef.current = Math.min(dpr ?? (window.devicePixelRatio || 1), 2);
|
||||
currentDprRef.current = baseDprRef.current;
|
||||
|
||||
renderer.setPixelRatio(currentDprRef.current);
|
||||
renderer.shadowMap.enabled = false;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(0x000000, 1);
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.display = 'block';
|
||||
mount.appendChild(canvas);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3));
|
||||
|
||||
const uniforms = {
|
||||
iTime: { value: 0 },
|
||||
iResolution: { value: new THREE.Vector3(1, 1, 1) },
|
||||
iMouse: { value: new THREE.Vector4(0, 0, 0, 0) },
|
||||
uWispDensity: { value: wispDensity },
|
||||
uTiltScale: { value: mouseTiltStrength },
|
||||
uFlowTime: { value: 0 },
|
||||
uFogTime: { value: 0 },
|
||||
uBeamXFrac: { value: horizontalBeamOffset },
|
||||
uBeamYFrac: { value: verticalBeamOffset },
|
||||
uFlowSpeed: { value: flowSpeed },
|
||||
uVLenFactor: { value: verticalSizing },
|
||||
uHLenFactor: { value: horizontalSizing },
|
||||
uFogIntensity: { value: fogIntensity },
|
||||
uFogScale: { value: fogScale },
|
||||
uWSpeed: { value: wispSpeed },
|
||||
uWIntensity: { value: wispIntensity },
|
||||
uFlowStrength: { value: flowStrength },
|
||||
uDecay: { value: decay },
|
||||
uFalloffStart: { value: falloffStart },
|
||||
uFogFallSpeed: { value: fogFallSpeed },
|
||||
uColor: { value: new THREE.Vector3(1, 1, 1) },
|
||||
uFade: { value: hasFadedRef.current ? 1 : 0 }
|
||||
};
|
||||
uniformsRef.current = uniforms;
|
||||
|
||||
const material = new THREE.RawShaderMaterial({
|
||||
vertexShader: VERT,
|
||||
fragmentShader: FRAG,
|
||||
uniforms,
|
||||
transparent: false,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
blending: THREE.NormalBlending
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.frustumCulled = false;
|
||||
scene.add(mesh);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let prevTime = 0;
|
||||
let fade = hasFadedRef.current ? 1 : 0;
|
||||
|
||||
const mouseTarget = new THREE.Vector2(0, 0);
|
||||
const mouseSmooth = new THREE.Vector2(0, 0);
|
||||
|
||||
const setSizeNow = () => {
|
||||
const w = mount.clientWidth || 1;
|
||||
const h = mount.clientHeight || 1;
|
||||
const pr = currentDprRef.current;
|
||||
|
||||
const last = lastSizeRef.current;
|
||||
const sizeChanged = Math.abs(w - last.width) > 0.5 || Math.abs(h - last.height) > 0.5;
|
||||
const dprChanged = Math.abs(pr - last.dpr) > 0.01;
|
||||
if (!sizeChanged && !dprChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSizeRef.current = { width: w, height: h, dpr: pr };
|
||||
renderer.setPixelRatio(pr);
|
||||
renderer.setSize(w, h, false);
|
||||
uniforms.iResolution.value.set(w * pr, h * pr, pr);
|
||||
rectRef.current = canvas.getBoundingClientRect();
|
||||
|
||||
if (!pausedRef.current) {
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
};
|
||||
|
||||
let resizeRaf = 0;
|
||||
const scheduleResize = () => {
|
||||
if (resizeRaf) cancelAnimationFrame(resizeRaf);
|
||||
resizeRaf = requestAnimationFrame(setSizeNow);
|
||||
};
|
||||
|
||||
setSizeNow();
|
||||
const ro = new ResizeObserver(scheduleResize);
|
||||
ro.observe(mount);
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
entries => {
|
||||
inViewRef.current = entries[0]?.isIntersecting ?? true;
|
||||
},
|
||||
{ root: null, threshold: 0 }
|
||||
);
|
||||
io.observe(mount);
|
||||
|
||||
const onVis = () => {
|
||||
pausedRef.current = document.hidden;
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVis, { passive: true });
|
||||
|
||||
const updateMouse = (clientX, clientY) => {
|
||||
const rect = rectRef.current;
|
||||
if (!rect) return;
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
const ratio = currentDprRef.current;
|
||||
const hb = rect.height * ratio;
|
||||
mouseTarget.set(x * ratio, hb - y * ratio);
|
||||
};
|
||||
const onMove = ev => updateMouse(ev.clientX, ev.clientY);
|
||||
const onLeave = () => mouseTarget.set(0, 0);
|
||||
canvas.addEventListener('pointermove', onMove, { passive: true });
|
||||
canvas.addEventListener('pointerdown', onMove, { passive: true });
|
||||
canvas.addEventListener('pointerenter', onMove, { passive: true });
|
||||
canvas.addEventListener('pointerleave', onLeave, { passive: true });
|
||||
|
||||
const onCtxLost = e => {
|
||||
e.preventDefault();
|
||||
pausedRef.current = true;
|
||||
};
|
||||
const onCtxRestored = () => {
|
||||
pausedRef.current = false;
|
||||
scheduleResize();
|
||||
};
|
||||
canvas.addEventListener('webglcontextlost', onCtxLost, false);
|
||||
canvas.addEventListener('webglcontextrestored', onCtxRestored, false);
|
||||
|
||||
let raf = 0;
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const dprFloor = 0.6;
|
||||
const lowerThresh = 50;
|
||||
const upperThresh = 58;
|
||||
let lastDprChangeRef = 0;
|
||||
const dprChangeCooldown = 2000;
|
||||
|
||||
const adjustDprIfNeeded = now => {
|
||||
const elapsed = now - lastFpsCheckRef.current;
|
||||
if (elapsed < 750) return;
|
||||
|
||||
const samples = fpsSamplesRef.current;
|
||||
if (samples.length === 0) {
|
||||
lastFpsCheckRef.current = now;
|
||||
return;
|
||||
}
|
||||
const avgFps = samples.reduce((a, b) => a + b, 0) / samples.length;
|
||||
|
||||
let next = currentDprRef.current;
|
||||
const base = baseDprRef.current;
|
||||
|
||||
if (avgFps < lowerThresh) {
|
||||
next = clamp(currentDprRef.current * 0.85, dprFloor, base);
|
||||
} else if (avgFps > upperThresh && currentDprRef.current < base) {
|
||||
next = clamp(currentDprRef.current * 1.1, dprFloor, base);
|
||||
}
|
||||
|
||||
if (Math.abs(next - currentDprRef.current) > 0.01 && now - lastDprChangeRef > dprChangeCooldown) {
|
||||
currentDprRef.current = next;
|
||||
lastDprChangeRef = now;
|
||||
setSizeNow();
|
||||
}
|
||||
|
||||
fpsSamplesRef.current = [];
|
||||
lastFpsCheckRef.current = now;
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
raf = requestAnimationFrame(animate);
|
||||
if (pausedRef.current || !inViewRef.current) return;
|
||||
|
||||
const t = clock.getElapsedTime();
|
||||
const dt = Math.max(0, t - prevTime);
|
||||
prevTime = t;
|
||||
|
||||
const dtMs = dt * 1000;
|
||||
emaDtRef.current = emaDtRef.current * 0.9 + dtMs * 0.1;
|
||||
const instFps = 1000 / Math.max(1, emaDtRef.current);
|
||||
fpsSamplesRef.current.push(instFps);
|
||||
|
||||
uniforms.iTime.value = t;
|
||||
|
||||
const cdt = Math.min(0.033, Math.max(0.001, dt));
|
||||
uniforms.uFlowTime.value += cdt;
|
||||
uniforms.uFogTime.value += cdt;
|
||||
|
||||
if (!hasFadedRef.current) {
|
||||
const fadeDur = 1.0;
|
||||
fade = Math.min(1, fade + cdt / fadeDur);
|
||||
uniforms.uFade.value = fade;
|
||||
if (fade >= 1) hasFadedRef.current = true;
|
||||
}
|
||||
|
||||
const tau = Math.max(1e-3, mouseSmoothTime);
|
||||
const alpha = 1 - Math.exp(-cdt / tau);
|
||||
mouseSmooth.lerp(mouseTarget, alpha);
|
||||
uniforms.iMouse.value.set(mouseSmooth.x, mouseSmooth.y, 0, 0);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
|
||||
adjustDprIfNeeded(performance.now());
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
io.disconnect();
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
canvas.removeEventListener('pointermove', onMove);
|
||||
canvas.removeEventListener('pointerdown', onMove);
|
||||
canvas.removeEventListener('pointerenter', onMove);
|
||||
canvas.removeEventListener('pointerleave', onLeave);
|
||||
canvas.removeEventListener('webglcontextlost', onCtxLost);
|
||||
canvas.removeEventListener('webglcontextrestored', onCtxRestored);
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
renderer.dispose();
|
||||
renderer.forceContextLoss();
|
||||
if (mount.contains(canvas)) mount.removeChild(canvas);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dpr]);
|
||||
|
||||
useEffect(() => {
|
||||
const uniforms = uniformsRef.current;
|
||||
if (!uniforms) return;
|
||||
|
||||
uniforms.uWispDensity.value = wispDensity;
|
||||
uniforms.uTiltScale.value = mouseTiltStrength;
|
||||
uniforms.uBeamXFrac.value = horizontalBeamOffset;
|
||||
uniforms.uBeamYFrac.value = verticalBeamOffset;
|
||||
uniforms.uFlowSpeed.value = flowSpeed;
|
||||
uniforms.uVLenFactor.value = verticalSizing;
|
||||
uniforms.uHLenFactor.value = horizontalSizing;
|
||||
uniforms.uFogIntensity.value = fogIntensity;
|
||||
uniforms.uFogScale.value = fogScale;
|
||||
uniforms.uWSpeed.value = wispSpeed;
|
||||
uniforms.uWIntensity.value = wispIntensity;
|
||||
uniforms.uFlowStrength.value = flowStrength;
|
||||
uniforms.uDecay.value = decay;
|
||||
uniforms.uFalloffStart.value = falloffStart;
|
||||
uniforms.uFogFallSpeed.value = fogFallSpeed;
|
||||
|
||||
const { r, g, b } = hexToRGB(color || '#FFFFFF');
|
||||
uniforms.uColor.value.set(r, g, b);
|
||||
}, [
|
||||
wispDensity,
|
||||
mouseTiltStrength,
|
||||
horizontalBeamOffset,
|
||||
verticalBeamOffset,
|
||||
flowSpeed,
|
||||
verticalSizing,
|
||||
horizontalSizing,
|
||||
fogIntensity,
|
||||
fogScale,
|
||||
wispSpeed,
|
||||
wispIntensity,
|
||||
flowStrength,
|
||||
decay,
|
||||
falloffStart,
|
||||
fogFallSpeed,
|
||||
color
|
||||
]);
|
||||
|
||||
return <div ref={mountRef} className={`laser-flow-container ${className || ''}`} style={style} />;
|
||||
};
|
||||
|
||||
export default LaserFlow;
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
// @ts-expect-error - react-bits JS component (ships without type declarations)
|
||||
import CircularGallery from "@/components/CircularGallery";
|
||||
|
||||
// Real lead photos of each metal from Wikimedia Commons (accurate, stable).
|
||||
// ponytail: external host (upload.wikimedia.org). If blocked in Iran, bundle under public/.
|
||||
const METALS = [
|
||||
{
|
||||
image:
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/%D0%90%D1%80%D0%BC%D0%B0%D1%82%D1%83%D1%80%D0%BD%D1%8B%D0%B9_%D0%BF%D1%80%D0%BE%D0%BA%D0%B0%D1%82.jpg/960px-%D0%90%D1%80%D0%BC%D0%B0%D1%82%D1%83%D1%80%D0%BD%D1%8B%D0%B9_%D0%BF%D1%80%D0%BE%D0%BA%D0%B0%D1%82.jpg",
|
||||
text: "فولاد",
|
||||
},
|
||||
{
|
||||
image:
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/NatCopper.jpg/960px-NatCopper.jpg",
|
||||
text: "مس",
|
||||
},
|
||||
{
|
||||
image: "https://upload.wikimedia.org/wikipedia/commons/5/5d/Aluminium-4.jpg",
|
||||
text: "آلومینیوم",
|
||||
},
|
||||
{
|
||||
image:
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/Nickel_electrolytic_and_1cm3_cube.jpg/960px-Nickel_electrolytic_and_1cm3_cube.jpg",
|
||||
text: "نیکل",
|
||||
},
|
||||
{
|
||||
image:
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f9/Zinc_fragment_sublimed_and_1cm3_cube.jpg/960px-Zinc_fragment_sublimed_and_1cm3_cube.jpg",
|
||||
text: "روی",
|
||||
},
|
||||
{
|
||||
image:
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/e/e6/Lead_electrolytic_and_1cm3_cube.jpg/960px-Lead_electrolytic_and_1cm3_cube.jpg",
|
||||
text: "سرب",
|
||||
},
|
||||
{
|
||||
image:
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/2b/Sn-Alpha-Beta.jpg/960px-Sn-Alpha-Beta.jpg",
|
||||
text: "قلع",
|
||||
},
|
||||
{ image: "https://upload.wikimedia.org/wikipedia/commons/2/2e/HematitaEZ.jpg", text: "سنگآهن" },
|
||||
{
|
||||
image:
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Gold-crystals.jpg/960px-Gold-crystals.jpg",
|
||||
text: "طلا",
|
||||
},
|
||||
{
|
||||
image:
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Silver_crystal.jpg/960px-Silver_crystal.jpg",
|
||||
text: "نقره",
|
||||
},
|
||||
{
|
||||
image:
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Ferrosilicon.JPG/960px-Ferrosilicon.JPG",
|
||||
text: "فروسیلیس",
|
||||
},
|
||||
{
|
||||
image:
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/A_bunch_of_rebar.jpg/960px-A_bunch_of_rebar.jpg",
|
||||
text: "میلگرد",
|
||||
},
|
||||
];
|
||||
|
||||
export function MetalsGallery() {
|
||||
return (
|
||||
<section className="bg-background py-16 sm:py-20">
|
||||
<div className="mx-auto mb-4 max-w-6xl px-5 text-center">
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">محصولات فولادی و فلزات</h2>
|
||||
<p className="mt-3 text-muted-foreground">
|
||||
دهها فلز و محصول فولادی که قیمتشان را دنبال میکنیم — بکش و بچرخان.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative h-[480px] w-full">
|
||||
<CircularGallery
|
||||
items={METALS}
|
||||
bend={2}
|
||||
textColor="#1d2330"
|
||||
borderRadius={0.06}
|
||||
scrollEase={0.05}
|
||||
scrollSpeed={2}
|
||||
font="bold 28px Vazirmatn"
|
||||
fontUrl="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@700&display=swap"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { FileText } from "lucide-react";
|
||||
// @ts-expect-error - react-bits JS component (ships without type declarations)
|
||||
import CardSwap, { Card } from "@/components/CardSwap";
|
||||
|
||||
const SOURCES = [
|
||||
{
|
||||
name: "Fastmarkets",
|
||||
tag: "فلزات و فولاد",
|
||||
items: ["فلزات پایه", "سنگها و آلیاژها", "فولاد و گزارش قیمت", "ضایعات و مواد اولیه فولاد"],
|
||||
},
|
||||
{
|
||||
name: "FerroAlloyNet",
|
||||
tag: "فروآلیاژ",
|
||||
items: [
|
||||
"آلومینیوم، کروم، فروسیلیس",
|
||||
"سنگآهن، منگنز، منیزیم",
|
||||
"مولیبدن، نیکل، تیتانیوم",
|
||||
"تنگستن، وانادیوم، زیرکن",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Platts (S&P)",
|
||||
tag: "قیمت روزانه",
|
||||
items: ["Metals Daily — قیمت روزانهی فلزات"],
|
||||
},
|
||||
{
|
||||
name: "Delphica",
|
||||
tag: "فولاد CIS",
|
||||
items: ["قیمتهای هفتگی فولاد CIS", "قیمتهای فولاد"],
|
||||
},
|
||||
{
|
||||
name: "Baiinfo",
|
||||
tag: "بازار چین",
|
||||
items: ["گزارش بازار فولاد چین"],
|
||||
},
|
||||
];
|
||||
|
||||
export function ReportSources() {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-[#0b1018] py-16 text-white sm:py-24">
|
||||
<div className="relative mx-auto max-w-6xl px-5 sm:min-h-[460px]">
|
||||
{/* Text — stacked on mobile, left-aligned & vertically centered on sm+ */}
|
||||
<div className="z-10 flex max-w-md flex-col text-right sm:absolute sm:inset-y-0 sm:left-0 sm:justify-center sm:px-2">
|
||||
<span className="mb-4 inline-block w-fit rounded-full border border-white/15 bg-white/5 px-3 py-1 text-xs font-mono tracking-wider text-amber-300">
|
||||
منابع داده
|
||||
</span>
|
||||
<h2 className="text-3xl font-bold leading-tight sm:text-4xl">
|
||||
گزارش از معتبرترین منابع جهانی
|
||||
</h2>
|
||||
<p className="mt-4 leading-8 text-white/70">
|
||||
دادهها و گزارشهای ما از منابع تخصصیِ بازار فلزات و فولاد جمعآوری میشوند — از
|
||||
Fastmarkets و Platts تا FerroAlloyNet، Delphica و Baiinfo.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{SOURCES.map((s) => (
|
||||
<span
|
||||
key={s.name}
|
||||
className="rounded-full border border-white/15 bg-white/5 px-3 py-1 text-xs text-white/80"
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card stack — own box on mobile (below text); peeks bottom-right on sm+ */}
|
||||
<div className="relative mt-10 h-[300px] w-full overflow-hidden sm:static sm:mt-0 sm:h-auto sm:w-auto sm:overflow-visible">
|
||||
<CardSwap
|
||||
width={320}
|
||||
height={230}
|
||||
cardDistance={30}
|
||||
verticalDistance={40}
|
||||
delay={3500}
|
||||
pauseOnHover={false}
|
||||
skewAmount={9}
|
||||
easing="linear"
|
||||
>
|
||||
{SOURCES.map((s) => (
|
||||
<Card key={s.name} className="border-white/20 p-6">
|
||||
<div className="flex items-center gap-2 text-amber-300">
|
||||
<FileText className="h-5 w-5" />
|
||||
<span className="font-mono text-xs tracking-wider">{s.tag}</span>
|
||||
</div>
|
||||
<h3 className="mt-3 text-2xl font-bold text-white">{s.name}</h3>
|
||||
<ul className="mt-3 space-y-1.5 text-sm leading-6 text-white/70">
|
||||
{s.items.map((it) => (
|
||||
<li key={it}>• {it}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</CardSwap>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,164 @@
|
|||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
|
||||
type ReportItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
const REPORTS: ReportItem[] = [
|
||||
{
|
||||
id: "domestic",
|
||||
title: "بازار داخلی — ارز، طلا و سکه",
|
||||
description: "نرخ زندهی ارز، طلا و سکه؛ هر ۶۰ ثانیه با فلش رنگیِ تغییر.",
|
||||
href: "/dashboard",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1610375461246-83df859d849d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1080",
|
||||
},
|
||||
{
|
||||
id: "commodity",
|
||||
title: "کامودیتی جهانی",
|
||||
description: "قیمت لحظهای نفت، طلا، فلزات و محصولات کشاورزی از بازارهای جهانی.",
|
||||
href: "/dashboard",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1582486225644-4a86c01a4f1c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1080",
|
||||
},
|
||||
{
|
||||
id: "metals",
|
||||
title: "فلزات و فولاد",
|
||||
description: "۶۷۴ محصولِ فلزی و فولادی با نمودار تاریخی و مقایسهی تا ۵ مورد.",
|
||||
href: "/dashboard",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1535813547-99c456a41d4a?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1080",
|
||||
},
|
||||
{
|
||||
id: "world",
|
||||
title: "اقتصاد جهانی",
|
||||
description: "شاخصهای کلانِ اقتصادهای بزرگ بهصورت نقشهی حرارتی.",
|
||||
href: "/dashboard",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1526304640581-d334cdbbf45e?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1080",
|
||||
},
|
||||
{
|
||||
id: "steel",
|
||||
title: "سهام فولادی",
|
||||
description: "بزرگترین شرکتهای فولادی جهان بر اساس ارزش بازار، هر ۵ دقیقه.",
|
||||
href: "/dashboard",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1565008447742-97f6f38c985c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1080",
|
||||
},
|
||||
];
|
||||
|
||||
export function ReportsGallery() {
|
||||
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
||||
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = useState(false);
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!carouselApi) return;
|
||||
const update = () => {
|
||||
setCanScrollPrev(carouselApi.canScrollPrev());
|
||||
setCanScrollNext(carouselApi.canScrollNext());
|
||||
setCurrentSlide(carouselApi.selectedScrollSnap());
|
||||
};
|
||||
update();
|
||||
carouselApi.on("select", update);
|
||||
return () => {
|
||||
carouselApi.off("select", update);
|
||||
};
|
||||
}, [carouselApi]);
|
||||
|
||||
return (
|
||||
<section className="py-20 sm:py-28">
|
||||
<div className="mx-auto max-w-6xl px-5">
|
||||
<div className="mb-8 flex items-end justify-between md:mb-12">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-3xl font-bold md:text-4xl">گزارشها و بازارها</h2>
|
||||
<p className="max-w-xl text-muted-foreground leading-7">
|
||||
هر بخش از سامانهی دیدوان را اینجا ببینید؛ از بازار داخلی و کامودیتی تا فلزات، اقتصاد
|
||||
جهانی و سهام فولادی.
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden shrink-0 gap-2 md:flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => carouselApi?.scrollPrev()}
|
||||
disabled={!canScrollPrev}
|
||||
className="disabled:opacity-40"
|
||||
aria-label="قبلی"
|
||||
>
|
||||
<ArrowRight className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => carouselApi?.scrollNext()}
|
||||
disabled={!canScrollNext}
|
||||
className="disabled:opacity-40"
|
||||
aria-label="بعدی"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Carousel setApi={setCarouselApi} opts={{ align: "start", direction: "rtl" }}>
|
||||
<CarouselContent className="mr-0 ml-0 px-5 sm:px-[max(1.25rem,calc(50vw-700px))]">
|
||||
{REPORTS.map((item) => (
|
||||
<CarouselItem key={item.id} className="basis-[300px] pr-5 lg:basis-[360px]">
|
||||
<a href={item.href} className="group block rounded-xl">
|
||||
<div className="relative h-full min-h-[26rem] overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col items-start p-6 text-white">
|
||||
<h3 className="mb-2 text-xl font-bold">{item.title}</h3>
|
||||
<p className="mb-6 line-clamp-2 text-sm text-white/80 leading-6">
|
||||
{item.description}
|
||||
</p>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-amber-300">
|
||||
مشاهده
|
||||
<ArrowLeft className="size-4 transition-transform group-hover:-translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
<div className="mt-8 flex justify-center gap-2">
|
||||
{REPORTS.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => carouselApi?.scrollTo(index)}
|
||||
aria-label={`اسلاید ${index + 1}`}
|
||||
className={`h-2 w-2 rounded-full transition-colors ${
|
||||
currentSlide === index ? "bg-primary" : "bg-primary/25"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface NavItem {
|
||||
name: string;
|
||||
url?: string; // route mode (landing). Omit for controlled/state mode (dashboard)
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface NavBarProps {
|
||||
items: NavItem[];
|
||||
className?: string;
|
||||
/** false → render inline (no fixed positioning), e.g. inside a header */
|
||||
floating?: boolean;
|
||||
/** controlled active name; when set the component is driven by the parent */
|
||||
active?: string;
|
||||
onSelect?: (name: string) => void;
|
||||
/** "lg" → roomier pill + larger icons (used for the mobile bottom bar) */
|
||||
size?: "default" | "lg";
|
||||
}
|
||||
|
||||
type Pos = { left: number; width: number; height: number };
|
||||
|
||||
export function TubelightNavbar({
|
||||
items,
|
||||
className,
|
||||
floating = true,
|
||||
active,
|
||||
onSelect,
|
||||
size = "default",
|
||||
}: NavBarProps) {
|
||||
const lg = size === "lg";
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const btnRefs = useRef<Record<string, HTMLElement | null>>({});
|
||||
|
||||
const location = useLocation();
|
||||
const routeActive = items.find((i) => i.url === location.pathname)?.name ?? items[0].name;
|
||||
const [internal, setInternal] = useState(active ?? routeActive);
|
||||
const activeTab = active ?? internal;
|
||||
|
||||
useEffect(() => {
|
||||
if (active === undefined) setInternal(routeActive);
|
||||
}, [active, routeActive]);
|
||||
|
||||
// ── Edge-fade "swipe me" hint when the bar overflows ──
|
||||
const [scrollable, setScrollable] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const check = () => setScrollable(el.scrollWidth > el.clientWidth + 4);
|
||||
check();
|
||||
const ro = new ResizeObserver(check);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [items.length]);
|
||||
const fadeMask: CSSProperties | undefined = scrollable
|
||||
? {
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent 0, #000 18px, #000 calc(100% - 18px), transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent 0, #000 18px, #000 calc(100% - 18px), transparent 100%)",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// ── Measure the active tab so the glass pill can slide to it ──
|
||||
const [pos, setPos] = useState<Pos | null>(null);
|
||||
const prevLeft = useRef(0);
|
||||
useEffect(() => {
|
||||
const measure = () => {
|
||||
const el = btnRefs.current[activeTab];
|
||||
if (el) setPos({ left: el.offsetLeft, width: el.offsetWidth, height: el.offsetHeight });
|
||||
};
|
||||
measure();
|
||||
const el = innerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [activeTab, items.length, lg]);
|
||||
|
||||
// Squash toward the travel direction, like a drop of water settling.
|
||||
const movingRight = pos ? pos.left >= prevLeft.current : true;
|
||||
if (pos) prevLeft.current = pos.left;
|
||||
const glide = { type: "spring" as const, stiffness: 320, damping: 30, mass: 0.9 };
|
||||
|
||||
const renderInner = (item: NavItem): ReactNode => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<>
|
||||
<span className="hidden md:inline">{item.name}</span>
|
||||
<span className="md:hidden">
|
||||
<Icon size={lg ? 24 : 18} strokeWidth={2.5} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const itemClass = (isActive: boolean) =>
|
||||
cn(
|
||||
"relative z-10 cursor-pointer rounded-full font-semibold whitespace-nowrap transition-[color,transform] duration-200",
|
||||
lg ? "px-5 py-3 text-base" : "px-5 py-2 text-sm",
|
||||
isActive ? "text-foreground" : "text-foreground/70 hover:text-primary hover:scale-[1.06]",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
floating
|
||||
? "fixed bottom-0 sm:top-0 left-1/2 -translate-x-1/2 z-50 mb-6 sm:pt-6"
|
||||
: "inline-flex",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={fadeMask}
|
||||
className={cn(
|
||||
"lg-bar flex max-w-[92vw] items-center overflow-x-auto rounded-full",
|
||||
lg ? "px-1.5 py-1.5" : "px-1 py-1",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={cn("relative flex w-max items-center", lg ? "gap-2" : "gap-1.5")}
|
||||
>
|
||||
{pos && (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="lg-pill pointer-events-none absolute left-0 top-1/2 z-0 block rounded-full"
|
||||
initial={false}
|
||||
style={{ transformOrigin: movingRight ? "left center" : "right center" }}
|
||||
animate={{
|
||||
x: pos.left,
|
||||
y: -pos.height / 2,
|
||||
width: pos.width,
|
||||
height: pos.height,
|
||||
scaleX: [1, 1.14, 1],
|
||||
}}
|
||||
transition={{
|
||||
x: glide,
|
||||
y: glide,
|
||||
width: glide,
|
||||
height: glide,
|
||||
scaleX: { duration: 0.44, ease: [1, 0, 0.4, 1], times: [0, 0.5, 1] },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{items.map((item) => {
|
||||
const isActive = activeTab === item.name;
|
||||
const handle = () => {
|
||||
setInternal(item.name);
|
||||
onSelect?.(item.name);
|
||||
};
|
||||
const ref = (el: HTMLElement | null) => {
|
||||
btnRefs.current[item.name] = el;
|
||||
};
|
||||
return item.url ? (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.url}
|
||||
ref={ref as never}
|
||||
onClick={handle}
|
||||
className={itemClass(isActive)}
|
||||
>
|
||||
{renderInner(item)}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
ref={ref as never}
|
||||
onClick={handle}
|
||||
className={itemClass(isActive)}
|
||||
>
|
||||
{renderInner(item)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium cursor-pointer transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
export { AspectRatio };
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbList.displayName = "BreadcrumbList";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
|
||||
),
|
||||
);
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn("bg-popover absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
|
||||
week_number: cn(
|
||||
"text-muted-foreground select-none text-[0.8rem]",
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn("bg-accent rounded-l-md", defaultClassNames.range_start),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
import * as React from "react";
|
||||
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
});
|
||||
Carousel.displayName = "Carousel";
|
||||
|
||||
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
CarouselContent.displayName = "CarouselContent";
|
||||
|
||||
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
CarouselItem.displayName = "CarouselItem";
|
||||
|
||||
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
CarouselPrevious.displayName = "CarouselPrevious";
|
||||
|
||||
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
CarouselNext.displayName = "CarouselNext";
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("grid place-content-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import * as React from "react";
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
if (!itemContext) {
|
||||
throw new Error("useFormField should be used within <FormItem>");
|
||||
}
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue | null>(null);
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = "FormMessage";
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-hover-card-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { Minus } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = "InputOTP";
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = "InputOTPGroup";
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-1 ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = "InputOTPSlot";
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator";
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
import * as React from "react";
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu {...props} />;
|
||||
}
|
||||
|
||||
function MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group {...props} />;
|
||||
}
|
||||
|
||||
function MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal {...props} />;
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return <MenubarPrimitive.RadioGroup {...props} />;
|
||||
}
|
||||
|
||||
function MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
|
||||
}
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-menubar-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-menubar-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
));
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
MenubarShortcut.displayname = "MenubarShortcut";
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
};
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
));
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent",
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
));
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
));
|
||||
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
};
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = "Pagination";
|
||||
|
||||
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
|
||||
),
|
||||
);
|
||||
PaginationContent.displayName = "PaginationContent";
|
||||
|
||||
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
|
||||
({ className, ...props }, ref) => <li ref={ref} className={cn("", className)} {...props} />,
|
||||
);
|
||||
PaginationItem.displayName = "PaginationItem";
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
PaginationLink.displayName = "PaginationLink";
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = "PaginationPrevious";
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = "PaginationNext";
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis";
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow cursor-pointer focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-3.5 w-3.5 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { GripVertical } from "lucide-react";
|
||||
import { Group, Panel, Separator } from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof Group>) => (
|
||||
<Group
|
||||
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<Separator
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</Separator>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue