THU figma redesign

This commit is contained in:
alireza 2026-06-11 11:00:17 +03:30
parent ee4892df75
commit 238464e073
25 changed files with 4267 additions and 202 deletions

View File

@ -73,7 +73,24 @@
"Bash(python assets/scripts/search.py \"steel industry admin dashboard CMS analytics RTL\" --design-system -p \"Foolad Panel\")",
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\DATIS STAR\\\\پنل\\\\src\\\\app\\\\api\\\\\" -Recurse -File)",
"Read(//c/Users/DATIS STAR/**)",
<<<<<<< HEAD
"Bash(git branch *)"
=======
"Bash(git branch *)",
"PowerShell(\\(Get-Item \"c:\\\\Users\\\\DATIS STAR\\\\andishkade-foolad\\\\public\\\\logo.png\"\\).Length)",
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\DATIS STAR\\\\andishkade-foolad\" -Force)",
"Bash(Select-Object Name, Mode)",
"Bash(pip --version)",
"Bash(pip install *)",
"Bash(python -m hf_xet install-filter)",
"Bash(hf_xet version *)",
"Bash(hf_xet --help)",
"Read(//c/Python314/Scripts/**)",
"Read(//c/Python314/Lib/site-packages/hf_xet/**)",
"Bash(huggingface-cli version *)",
"Bash(hf auth *)",
"Bash(hf upload *)"
>>>>>>> space/main
],
"additionalDirectories": [
"c:\\Users\\DATIS STAR\\پنل"

32
.gitignore vendored
View File

@ -1,3 +1,4 @@
<<<<<<< HEAD
# Logs
logs
*.log
@ -27,3 +28,34 @@ dist-ssr
/[0-9]*.png
brand_pages/
/موکاپ*.jpg
=======
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Root-level temp images (not website assets)
/[0-9]*.jpg
/[0-9]*.png
brand_pages/
/موکاپ*.jpg
>>>>>>> space/main

View File

@ -0,0 +1,249 @@
- generic [active] [ref=e1]:
- generic:
- img "Spread"
- generic: "100"
- navigation [ref=e2]:
- img "Spread" [ref=e4]
- list [ref=e5]:
- listitem [ref=e6]:
- link "How it works" [ref=e7] [cursor=pointer]:
- /url: "#how"
- listitem [ref=e8]:
- link "See a deal" [ref=e9] [cursor=pointer]:
- /url: "#deal"
- listitem [ref=e10]:
- link "Niches" [ref=e11] [cursor=pointer]:
- /url: "#niches"
- listitem [ref=e12]:
- link "Pricing" [ref=e13] [cursor=pointer]:
- /url: "#pricing"
- link "Get early access" [ref=e14] [cursor=pointer]:
- /url: /signup
- button "Get early access" [ref=e15]
- generic [ref=e26]:
- iframe [ref=e27]:
- region "Video Player" [ref=f2e2]:
- application [ref=f2e3]
- text:  
- generic [ref=e29]:
- heading "Find what's underpriced. Sell it for what it's worth." [level=1] [ref=e30]:
- generic [ref=e32]: Find
- generic [ref=e34]: what's
- generic [ref=e36]: underpriced.
- generic [ref=e38]: Sell
- generic [ref=e40]: it
- generic [ref=e42]: for
- generic [ref=e44]: what
- generic [ref=e46]: it's
- generic [ref=e48]: worth.
- paragraph [ref=e49]: AI that scans every marketplace, scores every deal, and helps you relist in one tap. Stop scrolling. Start flipping.
- generic [ref=e50]:
- link "Join the waitlist" [ref=e51] [cursor=pointer]:
- /url: /signup
- button "Join the waitlist" [ref=e52]:
- generic: Join the waitlist
- link "See it work →" [ref=e53] [cursor=pointer]:
- /url: "#deal"
- link "↗" [ref=e54] [cursor=pointer]:
- /url: /signup
- img [ref=e55]:
- generic [ref=e57]: CAPTURE THE SPREAD • FLIP SMARTER • CAPTURE THE SPREAD • FLIP SMARTER •
- generic:
- generic [ref=e59]:
- generic [ref=e60]: How it works
- heading "The AI finds deals you'd never see in time." [level=2] [ref=e61]
- paragraph [ref=e62]: Spread scans thousands of listings across Facebook Marketplace, Craigslist, OfferUp, eBay, and more — alerting you the second something surfaces 3060% below market value in your niches.
- generic [ref=e63]:
- strong [ref=e64]: 6 platforms.
- text: Scanned 24/7. Alerts in under 12 seconds.
- generic [ref=e67]:
- generic [ref=e68]: Score & relist
- heading "Know exactly what to offer. List it before you leave." [level=2] [ref=e69]
- paragraph [ref=e70]: Every deal gets an AI score — estimated resale value, projected margin, time-to-sell, and the best platform to relist on. One tap generates an optimized listing with AI-written descriptions.
- generic [ref=e71]:
- generic [ref=e74]: Spread AI · Deal Analysis
- generic [ref=e75]:
- generic [ref=e76]:
- text: Found a
- strong [ref=e77]: Breville Oracle Touch
- text: listed at $1,200
- generic [ref=e78]: "Market value: $1,800$2,100 on eBay"
- generic [ref=e79]: "Suggested offer: $750 · Projected profit: +$500"
- generic [ref=e80]: Generate opener message ↗
- generic [ref=e81]: "\"Hi — is this still available? I can pick up today. Would you take $750 cash?\""
- generic [ref=e82]: Opener copied · eBay listing drafted
- generic [ref=e86]:
- generic [ref=e87]:
- heading "Stop scrolling. Start flipping." [level=2] [ref=e88]
- paragraph [ref=e89]: Other flippers spend 20 hours a week refreshing marketplace feeds. You spend 20 minutes reviewing deals the AI already found.
- generic [ref=e90]:
- generic [ref=e91]:
- generic [ref=e92]: "0"
- generic [ref=e93]: Marketplaces scanned 24/7
- generic [ref=e94]:
- generic [ref=e95]: 0%
- generic [ref=e96]: Average margin per deal
- generic [ref=e97]:
- generic [ref=e98]: 0hrs
- generic [ref=e99]: Saved per week vs. manual
- generic [ref=e100]:
- generic [ref=e101]: 0s
- generic [ref=e102]: Avg. alert speed on new listings
- generic [ref=e104]:
- generic [ref=e105]: Live opportunities
- heading "This is what the AI surfaces every day." [level=2] [ref=e106]
- paragraph [ref=e107]: Real arbitrage opportunities — scored, analyzed, and ready for you to act on. This is what your feed looks like.
- generic [ref=e112]:
- generic:
- generic [ref=e113]:
- text: I used to spend 3 hours a day refreshing Marketplace. Now Spread sends me the good stuff and I just act on it. Made $1,200 my first month.
- generic [ref=e114]: The deal scoring is scary accurate. It told me to offer $380 on an Aeron listed at $520. I sold it for $710 on Apt Deco two days later.
- generic [ref=e115]: One-click cross-listing changed everything. I find it on Craigslist, hit relist, and the eBay listing writes itself. My volume tripled.
- generic [ref=e116]: Set up my espresso machine niche on a Sunday. Had three scored deals by Monday morning. Closed my first flip by Wednesday.
- generic [ref=e117]:
- text: Operator plan · Austin, TX
- generic [ref=e118]: Scout plan · Portland, OR
- generic [ref=e119]: Operator plan · Nashville, TN
- generic [ref=e120]: Scout plan · Denver, CO
- generic [ref=e121]:
- button "Sarah K." [ref=e122] [cursor=pointer]:
- generic [ref=e123]: Sarah K.
- button "Marcus T." [ref=e124] [cursor=pointer]:
- generic: Marcus T.
- button "David L." [ref=e125] [cursor=pointer]:
- generic: David L.
- button "Amy R." [ref=e126] [cursor=pointer]:
- generic: Amy R.
- generic [ref=e127]:
- generic [ref=e128]: Built for flippers
- generic [ref=e130]: Every marketplace has a pricing gap.
- generic [ref=e132]: The AI finds it. You capture it.
- generic [ref=e134]:
- generic [ref=e135]: Where we're headed
- heading "Your money. Your rules. Choose how hands-on you want to be." [level=2] [ref=e136]
- paragraph [ref=e137]: Today, you control every deal. Soon, the AI handles more — negotiation, auto-buy within your thresholds, and fully autonomous operation. You decide when to let go.
- generic [ref=e138]:
- generic [ref=e139]:
- button "Manual" [ref=e140] [cursor=pointer]
- button "Assisted SOON" [ref=e141] [cursor=pointer]
- button "Autopilot SOON" [ref=e142] [cursor=pointer]
- paragraph [ref=e144]: Available now. The AI finds deals, scores them, and drafts your relist. You decide what to buy, what to offer, and when to sell. Full control, full visibility, full profit tracking.
- generic [ref=e145]:
- generic [ref=e146]:
- generic [ref=e147]: "01"
- generic [ref=e148]: Deal found
- generic [ref=e149]: AI scans 6 platforms
- generic [ref=e150]:
- generic [ref=e151]: "02"
- generic [ref=e152]: Scored
- generic [ref=e153]: Profit + risk analysis
- generic [ref=e154]:
- generic [ref=e155]: "03"
- generic [ref=e156]: You act
- generic [ref=e157]: Message, offer, buy
- generic [ref=e158]:
- generic [ref=e159]: "04"
- generic [ref=e160]: Relist
- generic [ref=e161]: One-tap cross-listing
- generic [ref=e164]:
- generic [ref=e165]:
- generic [ref=e166]: Your niches
- heading "Pick the markets you know." [level=2] [ref=e167]
- paragraph [ref=e168]: Choose the categories you understand. The AI handles scanning, scoring, and listing across every marketplace.
- generic [ref=e169]:
- button "Espresso Machines $280" [ref=e170] [cursor=pointer]
- button "Camera Gear $320" [ref=e171] [cursor=pointer]
- button "Mid-Century Furniture $450" [ref=e172] [cursor=pointer]
- button "Power Tools $175" [ref=e173] [cursor=pointer]
- button "Commercial Kitchen $520" [ref=e174] [cursor=pointer]
- button "Audiophile Gear $210" [ref=e175] [cursor=pointer]
- button "Outdoor Gear $140" [ref=e176] [cursor=pointer]
- button "Vintage Watches $380" [ref=e177] [cursor=pointer]
- button "Sneakers $95" [ref=e178] [cursor=pointer]
- button "Vinyl Records $85" [ref=e179] [cursor=pointer]
- button "Mechanical Keyboards $120" [ref=e180] [cursor=pointer]
- button "Designer Handbags $340" [ref=e181] [cursor=pointer]
- generic [ref=e183]:
- paragraph [ref=e184]: Or search your own
- generic [ref=e185]:
- textbox "Search a niche — vintage watches, vinyl records..." [ref=e186]
- button [ref=e187] [cursor=pointer]:
- img [ref=e188]
- generic [ref=e193]:
- heading "Opportunities land while you sleep." [level=2] [ref=e194]
- paragraph [ref=e195]: The AI never stops scanning. Wake up to scored deals ready for your review.
- generic [ref=e196]:
- generic [ref=e197]:
- generic [ref=e198]:
- generic [ref=e199]: Pricing
- heading "One good flip pays for a year." [level=2] [ref=e200]
- generic [ref=e201]: No speed throttling, no alert delays. Every plan gets the same fast scanning. Upgrade for more niches and advanced tools.
- generic [ref=e202]:
- generic [ref=e203]:
- generic [ref=e204]: Starter
- generic [ref=e205]: Scout
- generic [ref=e206]: Find your first flips
- generic [ref=e207]:
- generic [ref=e208]: $29
- generic [ref=e209]: /mo
- generic [ref=e210]: Cancel anytime
- list [ref=e212]:
- listitem [ref=e213]: 2 active niches
- listitem [ref=e214]: Real-time deal alerts
- listitem [ref=e215]: AI deal scoring
- listitem [ref=e216]: Profit tracking dashboard
- link "Start free trial" [ref=e217] [cursor=pointer]:
- /url: /signup
- button "Start free trial" [ref=e218]
- generic [ref=e219]:
- generic [ref=e220]: Most popular
- generic [ref=e221]: Operator
- generic [ref=e222]: Scale your operation
- generic [ref=e223]:
- generic [ref=e224]: $49
- generic [ref=e225]: /mo
- generic [ref=e226]: Pays for itself in one flip
- list [ref=e228]:
- listitem [ref=e229]: 8 active niches
- listitem [ref=e230]: One-click cross-listing
- listitem [ref=e231]: AI opener messages
- listitem [ref=e232]: Niche trend intelligence
- link "Start free trial" [ref=e233] [cursor=pointer]:
- /url: /signup
- button "Start free trial" [ref=e234]
- generic [ref=e235]:
- generic [ref=e236]: Pro
- generic [ref=e237]: Syndicate
- generic [ref=e238]: For full-time flippers
- generic [ref=e239]:
- generic [ref=e240]: $99
- generic [ref=e241]: /mo
- generic [ref=e242]: Priority access to Autopilot
- list [ref=e244]:
- listitem [ref=e245]: Unlimited niches
- listitem [ref=e246]: Multi-platform analytics
- listitem [ref=e247]: Team seats + shared deals
- listitem [ref=e248]: "Early access: Assisted + Autopilot"
- link "Start free trial" [ref=e249] [cursor=pointer]:
- /url: /signup
- button "Start free trial" [ref=e250]
- generic [ref=e252]:
- heading "Built for the way you actually flip." [level=2] [ref=e253]
- paragraph [ref=e254]: Deals surface in the background. Your life stays in the foreground.
- generic [ref=e270]:
- heading "Every marketplace has a spread. Start capturing yours." [level=2] [ref=e271]:
- text: Every marketplace has a spread.
- text: Start capturing yours.
- paragraph [ref=e272]: Same item. Different platforms. Different prices. The AI finds the gap — you keep what's left.
- link "Start free trial" [ref=e273] [cursor=pointer]:
- /url: /signup
- button "Start free trial" [ref=e274]:
- generic: Start free trial
- contentinfo [ref=e275]:
- img "Spread" [ref=e277]
- generic [ref=e278]:
- link "Terms" [ref=e279] [cursor=pointer]:
- /url: /terms
- link "Privacy" [ref=e280] [cursor=pointer]:
- /url: /privacy
- generic [ref=e281]: © 2026 Spread

61
CLAUDE.md Normal file
View File

@ -0,0 +1,61 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Two independent apps in one repo:
1. **Frontend** (repo root) — a Vite + React 19 + TypeScript marketing/content site for "اندیشکده فولاد آینده" (a steel-industry think tank). Persian-first, RTL, bilingual (fa/en). Deploys as a static build via Docker to Hugging Face Spaces on port 7860.
2. **Admin panel** (`panel/`) — a **standalone** Node/Express + SQLite app with its own `package.json` and `node_modules`. Manages content the site is meant to publish. **The frontend does NOT consume the panel yet** — every section renders hardcoded mock data. Wiring them is a deliberate future step.
These two have **no shared build or dependency tree**. Treat `panel/` as a separate project.
## Commands
Frontend (run from repo root):
```bash
npm run dev # Vite dev server (HMR)
npm run build # tsc -b && vite build (type-checks then bundles)
npm run lint # eslint .
npm run preview # serve the production build
npx tsc -b --noEmit # type-check only — the fast inner loop; use after every edit
```
There is **no test framework** configured. "Verifying" a change means `npx tsc -b --noEmit` (must be clean) and looking at the running dev server.
Admin panel (run from `panel/`):
```bash
cd panel
npm install # first time only
npm run seed # create the admin user from .env (ADMIN_USERNAME/PASSWORD)
npm start # node server.js → http://localhost:3001
npm run dev # node --watch server.js
npm rebuild better-sqlite3 # REQUIRED if Node's ABI changed — native addon, fails to load otherwise
```
## Critical build gotcha
`panel/` lives inside the frontend's project root, so **Vite's dependency scanner reads `panel/package.json`**. If that file (or anything Vite scans) has unresolved git merge-conflict markers (`<<<<<<<`), `npm run dev`/`build` fails with a cryptic `JSONError ... expected value at line 1`. Keep `panel/package.json` valid JSON.
## Frontend architecture
- **Routing**: `src/app/router.tsx` (`createBrowserRouter`). `RootLayout` (`src/app/layout/RootLayout.tsx`) wraps every page with `Header`, `Footer`, and the smooth-scroll engine. Pages live in `src/pages/<Name>/<Name>.tsx`; the homepage composes section components from `src/pages/Home/sections/`.
- **Language**: `useLang()` from `src/context/LangContext` returns `{ lang: 'fa'|'en', toggle }`. Components hold inline `{ fa: {...}, en: {...} }` objects and index by `lang`. RTL is driven by `dir={lang==='fa'?'rtl':'ltr'}` near the page root; many flex containers force `direction:'ltr'` locally to stop RTL from reversing their child order, then set inner text back to `rtl`.
- **Smooth scroll**: `RootLayout` runs **Lenis synced to the GSAP ticker** (the joinspread.app recipe) — one shared RAF clock so scroll-driven animations don't stutter. It is **desktop-only**: on touch/coarse pointers it hands off to native momentum (avoids the iOS double-smoothing lag). Do not re-add CSS `scroll-behavior: smooth` — it fights Lenis.
- **Hero** (`src/pages/Home/sections/HeroSection.tsx`): a GSAP `ScrollTrigger` scrub timeline pinned over a `200vh` section (de-zoom + satellite scatter). This is GSAP, not framer-motion.
- **Styling**: mostly **inline `style` objects**, with Tailwind utility classes for responsive overrides (`max-md:`, `max-lg:`) and a few `!important` overrides. Global CSS variables in `src/index.css` (`--ink`, `--paper`, `--red`, `--color-orange`, etc.). Brand colors are **gold `#CD9E53`** and **navy `#032340`** — these are also hardcoded as `const GOLD`/`const NAVY` in many section files, so a rebrand is a repo-wide find-and-replace, not just a CSS-var change.
- **Globe** (`src/components/ui/globe.tsx`): three-globe mounted in react-three-fiber via `<primitive>` (NOT via `extend`/JSX-element augmentation — that augmentation poisons global JSX types and breaks unrelated components like `React.ElementType` icons). Country polygons load at runtime from `public/globe-countries.geojson` (bundled, public-domain Natural Earth).
- **Calendar** (`src/components/ui/EventCalendar.tsx`): a native Jalali (Persian) month grid built with `Intl.DateTimeFormat('en-US-u-ca-persian', …)`**no date library**. It walks Gregorian `Date`s and reads their Persian parts to lay out the grid.
- **Content layer**: `src/content/*.ts` and inline arrays hold all section data. These are mock/seed data and are explicitly intended to be replaced by `fetch` calls to the panel API later (see "Wiring", below). Keep new editorial data in this shape so the swap stays mechanical.
## Admin panel architecture (`panel/`)
- **Stack**: Express 4 + `better-sqlite3` (single file `panel/data.db`), `bcryptjs` + `jsonwebtoken` (HTTP-only cookie named `session`), `multer` uploads to `panel/uploads/`. Admin UI is a **vanilla-JS SPA** in `panel/public/` (`app.js`, no build step).
- **Schema**: `panel/db.js` (`users`, `articles`, `risk_signals`, `prices`) plus row→object mappers. `articles` is a flexible universal content model discriminated by `category`/`type`.
- **API** (`panel/server.js`): public reads (`GET /api/articles`, `/api/risks`, `/api/prices`) + auth-gated writes via the `authRequired` middleware. CRUD for articles, risk signals, and users; `/api/uploads`; `/api/contact`. CORS is gated by the `ALLOWED_ORIGINS` env var.
- **Prices** are scraped from tgju.org by `panel/scraper.js` on a 120s interval when the server runs.
- **Config**: `panel/.env` (copy from `.env.example`) — `JWT_SECRET`, `ADMIN_*`, `PORT` (3001), `ALLOWED_ORIGINS`, mail creds.
## Wiring the panel to the site (not done yet)
When asked to make published content appear on the site: add a frontend fetch client (e.g. `src/lib/api.ts` reading `import.meta.env.VITE_API_URL`), replace a section's `src/content/*` import with a hook that calls `GET /api/articles?category=<section>`, and keep the static file as the loading fallback. Do it one section at a time. The `articles` table's `category` column is the per-section discriminator. Recommended schema additions before going live: a `status` (draft/published) column, and an `events` resource for the calendar.

290
SECURITY.md Normal file
View File

@ -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.

681
live_dashboard.html Normal file
View File

@ -0,0 +1,681 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🪙 داشبورد زنده قیمت سکه — اندیشکده فولاد آینده</title>
<!-- Modern Persian & English Typography -->
<link href="https://cdn.jsdelivr.net/gh/rastikerdar/vazir-font@v30.1.0/dist/font-face.css" rel="stylesheet" type="text/css" />
<style>
:root {
--bg-gradient: linear-gradient(135deg, #0b0f19 0%, #111827 100%);
--card-bg: rgba(17, 24, 39, 0.7);
--card-border: rgba(255, 255, 255, 0.08);
--text-primary: #f3f4f6;
--text-muted: #9ca3af;
--accent-gold: #f59e0b;
--accent-gold-glow: rgba(245, 158, 11, 0.15);
--color-up: #10b981;
--color-up-glow: rgba(16, 185, 129, 0.2);
--color-down: #ef4444;
--color-down-glow: rgba(239, 68, 68, 0.2);
--color-neutral: #6b7280;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Vazir', 'Inter', -apple-system, sans-serif;
}
body {
background: var(--bg-gradient);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
padding: 2rem 1.5rem;
}
/* Container */
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
width: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 2rem;
}
/* Header */
header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1.5rem;
border-bottom: 1px solid var(--card-border);
padding-bottom: 1.5rem;
}
.brand-section {
display: flex;
align-items: center;
gap: 1rem;
}
.brand-logo {
font-size: 2.5rem;
animation: float 4s ease-in-out infinite;
}
h1 {
font-size: 1.8rem;
font-weight: 800;
background: linear-gradient(to left, #ffffff, #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.2rem;
}
.brand-sub {
font-size: 0.85rem;
color: var(--text-muted);
letter-spacing: 0.5px;
}
/* Status Pill and Countdown */
.status-panel {
display: flex;
align-items: center;
gap: 1.2rem;
background: rgba(255, 255, 255, 0.03);
padding: 0.6rem 1.2rem;
border-radius: 50px;
border: 1px solid var(--card-border);
backdrop-filter: blur(10px);
}
.status-pill {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: bold;
color: var(--color-up);
}
.pulse-dot {
width: 8px;
height: 8px;
background: var(--color-up);
border-radius: 50%;
box-shadow: 0 0 0 0 var(--color-up-glow);
animation: pulse 1.5s infinite;
}
.timer-section {
font-size: 0.85rem;
color: var(--text-muted);
border-right: 1px solid var(--card-border);
padding-right: 1.2rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.timer-val {
font-weight: 900;
color: var(--accent-gold);
font-variant-numeric: tabular-nums;
}
/* main price grid */
.price-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
}
/* Card styling - Glassmorphism */
.coin-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 20px;
padding: 1.8rem 1.5rem;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 1.2rem;
position: relative;
overflow: hidden;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.3s, box-shadow 0.3s;
}
.coin-card:hover {
transform: translateY(-5px);
border-color: rgba(245, 158, 11, 0.3);
box-shadow: 0 12px 40px 0 rgba(245, 158, 11, 0.08);
}
/* Price Flashes */
@keyframes up-flash {
0% { border-color: var(--color-up); box-shadow: 0 0 25px var(--color-up-glow); }
100% { border-color: var(--card-border); box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); }
}
@keyframes down-flash {
0% { border-color: var(--color-down); box-shadow: 0 0 25px var(--color-down-glow); }
100% { border-color: var(--card-border); box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); }
}
.up-flash {
animation: up-flash 1.5s ease-out;
}
.down-flash {
animation: down-flash 1.5s ease-out;
}
/* Card Header */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.coin-title {
font-size: 1.1rem;
font-weight: 800;
color: #ffffff;
}
.coin-badge {
font-size: 1.5rem;
background: var(--accent-gold-glow);
padding: 0.4rem;
border-radius: 12px;
color: var(--accent-gold);
border: 1px solid rgba(245, 158, 11, 0.2);
}
/* Card Price Body */
.price-section {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.price-toman {
font-size: 1.8rem;
font-weight: 900;
color: #ffffff;
letter-spacing: -0.5px;
}
.price-toman span {
font-size: 0.85rem;
font-weight: normal;
color: var(--text-muted);
margin-right: 0.3rem;
}
.price-rial {
font-size: 0.8rem;
color: var(--text-muted);
}
/* Change badge indicators */
.change-badge {
align-self: flex-start;
font-size: 0.78rem;
font-weight: bold;
padding: 0.3rem 0.7rem;
border-radius: 50px;
display: flex;
align-items: center;
gap: 0.3rem;
direction: ltr; /* English layout inside pill */
}
.change-up {
background: rgba(16, 185, 129, 0.1);
color: var(--color-up);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.change-down {
background: rgba(239, 68, 68, 0.1);
color: var(--color-down);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.change-neutral {
background: rgba(107, 114, 128, 0.1);
color: var(--color-neutral);
border: 1px solid rgba(107, 114, 128, 0.2);
}
/* Card Footer Info */
.card-footer {
border-top: 1px solid rgba(255, 255, 255, 0.04);
padding-top: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--text-muted);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-val {
font-weight: bold;
color: #ffffff;
}
/* History table area */
.history-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 20px;
padding: 1.8rem;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.history-title {
font-size: 1.2rem;
font-weight: 800;
color: #ffffff;
display: flex;
align-items: center;
gap: 0.5rem;
}
.table-container {
overflow-x: auto;
border-radius: 12px;
border: 1px solid var(--card-border);
}
table {
width: 100%;
border-collapse: collapse;
text-align: right;
font-size: 0.88rem;
}
th {
background: rgba(255, 255, 255, 0.02);
color: var(--text-muted);
font-weight: bold;
padding: 1rem;
border-bottom: 1px solid var(--card-border);
}
td {
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
color: #e5e7eb;
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: rgba(255, 255, 255, 0.01);
}
/* Animations */
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
footer {
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
margin-top: 3rem;
border-top: 1px solid var(--card-border);
padding-top: 1.5rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
body {
padding: 1rem;
}
header {
flex-direction: column;
align-items: flex-start;
}
.status-panel {
width: 100%;
justify-content: space-between;
}
}
</style>
</head>
<body>
<div class="dashboard-container">
<!-- Brand Header -->
<header>
<div class="brand-section">
<div class="brand-logo">🪙</div>
<div>
<h1>داشبورد زنده قیمت سکه</h1>
<div class="brand-sub">اندیشکده فولاد آینده — مرکز تحلیل راهبردی بازار طلا و صنعت</div>
</div>
</div>
<!-- Real-time updates panel -->
<div class="status-panel">
<div class="status-pill">
<div class="pulse-dot"></div>
<span>بروزرسانی زنده فعال</span>
</div>
<div class="timer-section">
<span>داده جدید در: </span>
<span class="timer-val" id="countdown-val">۶۰ ثانیه</span>
</div>
</div>
</header>
<!-- 5 Live Coin Cards -->
<section class="price-grid" id="cards-grid">
<!-- Cards will be dynamically rendered here -->
</section>
<!-- Recent History Log Table -->
<section class="history-card">
<div class="history-header">
<h2 class="history-title">⏳ تاریخچه نوسانات اخیر (ثبت خودکار هر ۱ دقیقه)</h2>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>زمان ثبت سیستم</th>
<th>نام سکه</th>
<th>قیمت فعلی (تومان)</th>
<th>میزان و درصد تغییر</th>
<th>کمترین قیمت روز</th>
<th>بیشترین قیمت روز</th>
<th>زمان بروزرسانی سایت</th>
</tr>
</thead>
<tbody id="history-tbody">
<tr>
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 2rem;">در حال لود اطلاعات از سرور...</td>
</tr>
</tbody>
</table>
</div>
</section>
<footer>
مرکز مستقل تحلیل راهبردی و سیاست‌گذاری بازار و صنایع — تمامی نرخ‌ها به صورت مستقیم و بدون کش از بازار تهران دریافت می‌شوند.
</footer>
</div>
<!-- Real-time Dynamic script re-loader and state manager -->
<script>
let lastFetchedTime = null;
let priceHistory = [];
let previousPrices = {};
let countdownSecs = 60;
// Format numbers helper
function fmtNum(val) {
if (typeof val === 'string') {
val = parseInt(val.replace(/[^\d]/g, ''));
}
return val ? val.toLocaleString('fa-IR') : '۰';
}
// Convert numbers to Persian characters
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
function toPersianDigits(str) {
if (str === null || str === undefined) return '';
return str.toString().replace(/[0-9]/g, w => persianDigits[+w]);
}
// Load data from live_prices.js dynamically to bypass CORS file:// issues
function reloadPriceScript() {
// Remove any previously injected script
const oldScript = document.getElementById('live-data-script');
if (oldScript) {
oldScript.remove();
}
// Create a fresh script element with cache buster
const script = document.createElement('script');
script.id = 'live-data-script';
script.src = 'live_prices.js?_t=' + Date.now();
script.onload = function() {
if (window.LIVE_COIN_PRICES) {
updateUI(window.LIVE_COIN_PRICES);
}
};
document.body.appendChild(script);
}
function updateUI(data) {
// If no new data fetched since last load, do nothing
if (lastFetchedTime === data.last_fetch) {
return;
}
lastFetchedTime = data.last_fetch;
countdownSecs = 60; // Reset countdown on fresh fetch
const grid = document.getElementById('cards-grid');
grid.innerHTML = '';
// Target emojis
const coinEmojis = {
"سکه امامی": "👑",
"سکه بهار آزادی": "🕌",
"نیم سکه": "🌓",
"ربع سکه": "🌗",
"سکه گرمی": "⚡"
};
data.prices.forEach(coin => {
// Compare price with previous to determine change flashes
const oldPrice = previousPrices[coin.name];
let flashClass = '';
if (oldPrice !== undefined) {
if (coin.price_toman > oldPrice) flashClass = 'up-flash';
else if (coin.price_toman < oldPrice) flashClass = 'down-flash';
}
previousPrices[coin.name] = coin.price_toman;
// Parse change percentage and sign
const isUp = coin.change.includes('+') || (!coin.change.includes('-') && !coin.change.startsWith('0') && !coin.change.includes('(0%'));
const isZero = coin.change.startsWith('0') || coin.change.includes('(0%') || coin.change === '0';
let changeClass = 'change-neutral';
let changeIcon = '●';
if (!isZero) {
if (isUp) {
changeClass = 'change-up';
changeIcon = '▲';
} else {
changeClass = 'change-down';
changeIcon = '▼';
}
}
// Render card html
const emoji = coinEmojis[coin.name] || "🪙";
const cardHtml = `
<div class="coin-card ${flashClass}">
<div class="card-header">
<span class="coin-title">${coin.name}</span>
<span class="coin-badge">${emoji}</span>
</div>
<div class="price-section">
<div class="price-toman">
${fmtNum(coin.price_toman)} <span>تومان</span>
</div>
<div class="price-rial">
${fmtNum(coin.price_rial)} ریال
</div>
</div>
<div class="change-badge ${changeClass}">
<span>${toPersianDigits(coin.change)}</span>
<span>${changeIcon}</span>
</div>
<div class="card-footer">
<div class="info-row">
<span>کمترین قیمت روز:</span>
<span class="info-val">${fmtNum(coin.min_toman)} ت</span>
</div>
<div class="info-row">
<span>بیشترین قیمت روز:</span>
<span class="info-val">${fmtNum(coin.max_toman)} ت</span>
</div>
<div class="info-row" style="margin-top: 0.4rem; border-top: 1px dashed rgba(255,255,255,0.03); padding-top: 0.4rem;">
<span>بروزرسانی سایت:</span>
<span class="info-val" style="color: var(--accent-gold); font-weight: bold;">${toPersianDigits(coin.last_update)}</span>
</div>
</div>
</div>
`;
grid.insertAdjacentHTML('beforeend', cardHtml);
// Push record to local rolling history
pushToRollingHistory(data.last_fetch, coin);
});
// Update history table view
renderHistoryTable();
}
// Keep the last 15 ticks in local storage rolling history
function pushToRollingHistory(fetchTime, coin) {
// Avoid duplicate logs for the exact same fetch time
const duplicate = priceHistory.some(h => h.time === fetchTime && h.name === coin.name);
if (duplicate) return;
priceHistory.push({
time: fetchTime,
name: coin.name,
price_toman: coin.price_toman,
change: coin.change,
min_toman: coin.min_toman,
max_toman: coin.max_toman,
last_update: coin.last_update
});
// Limit size to last 50 entries
if (priceHistory.length > 50) {
priceHistory.shift();
}
localStorage.setItem('coin_price_history_v2', JSON.stringify(priceHistory));
}
function renderHistoryTable() {
const tbody = document.getElementById('history-tbody');
tbody.innerHTML = '';
// Display logs in reverse order (newest first)
const reversedLogs = [...priceHistory].reverse();
if (reversedLogs.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-muted);">هیچ رکوردی در تاریخچه ثبت نشده است.</td></tr>`;
return;
}
reversedLogs.forEach(row => {
const isUp = row.change.includes('+') || (!row.change.includes('-') && !row.change.startsWith('0') && !row.change.includes('(0%'));
const isZero = row.change.startsWith('0') || row.change.includes('(0%') || row.change === '0';
let color = 'var(--text-primary)';
if (!isZero) {
color = isUp ? 'var(--color-up)' : 'var(--color-down)';
}
const trHtml = `
<tr>
<td style="font-weight: bold; color: var(--accent-gold);">${toPersianDigits(row.time)}</td>
<td style="font-weight: bold;">${row.name}</td>
<td style="font-weight: 900;">${fmtNum(row.price_toman)} تومان</td>
<td style="color: ${color}; font-weight: bold; direction: ltr;">${toPersianDigits(row.change)}</td>
<td>${fmtNum(row.min_toman)} تومان</td>
<td>${fmtNum(row.max_toman)} تومان</td>
<td style="color: var(--text-muted);">${toPersianDigits(row.last_update)}</td>
</tr>
`;
tbody.insertAdjacentHTML('beforeend', trHtml);
});
}
// Initialize state
if (localStorage.getItem('coin_price_history_v2')) {
try {
priceHistory = JSON.parse(localStorage.getItem('coin_price_history_v2'));
} catch(e) {
priceHistory = [];
}
}
// Loop timer
setInterval(() => {
if (countdownSecs > 1) {
countdownSecs--;
document.getElementById('countdown-val').innerText = toPersianDigits(countdownSecs) + ' ثانیه';
} else {
document.getElementById('countdown-val').innerText = 'در حال دریافت...';
}
}, 1000);
// Initial loading
reloadPriceScript();
// Poll the JS file every 2 seconds for ultra-responsive updates
setInterval(reloadPriceScript, 2000);
</script>
</body>
</html>

50
live_prices.js Normal file
View File

@ -0,0 +1,50 @@
window.LIVE_COIN_PRICES = {
"last_fetch": "2026-06-01 16:15:58",
"prices": [
{
"name": "سکه امامی",
"price_rial": 1845050000,
"price_toman": 184505000,
"change": "(3.07%) 55,000,000",
"min_toman": 182480000,
"max_toman": 184510000,
"last_update": "16:13:54"
},
{
"name": "سکه بهار آزادی",
"price_rial": 1810100000,
"price_toman": 181010000,
"change": "(2.26%) 39,950,000",
"min_toman": 177980000,
"max_toman": 181020000,
"last_update": "16:14:00"
},
{
"name": "نیم سکه",
"price_rial": 940000000,
"price_toman": 94000000,
"change": "(2.1%) 19,300,000",
"min_toman": 93500000,
"max_toman": 94000000,
"last_update": "11:10:35"
},
{
"name": "ربع سکه",
"price_rial": 535000000,
"price_toman": 53500000,
"change": "(2.88%) 15,000,000",
"min_toman": 52500000,
"max_toman": 53500000,
"last_update": "15:38:23"
},
{
"name": "سکه گرمی",
"price_rial": 275000000,
"price_toman": 27500000,
"change": "(1.85%) 5,000,000",
"min_toman": 27000000,
"max_toman": 27500000,
"last_update": "15:55:20"
}
]
};

50
live_prices.json Normal file
View File

@ -0,0 +1,50 @@
{
"last_fetch": "2026-06-01 16:15:58",
"prices": [
{
"name": "سکه امامی",
"price_rial": 1845050000,
"price_toman": 184505000,
"change": "(3.07%) 55,000,000",
"min_toman": 182480000,
"max_toman": 184510000,
"last_update": "16:13:54"
},
{
"name": "سکه بهار آزادی",
"price_rial": 1810100000,
"price_toman": 181010000,
"change": "(2.26%) 39,950,000",
"min_toman": 177980000,
"max_toman": 181020000,
"last_update": "16:14:00"
},
{
"name": "نیم سکه",
"price_rial": 940000000,
"price_toman": 94000000,
"change": "(2.1%) 19,300,000",
"min_toman": 93500000,
"max_toman": 94000000,
"last_update": "11:10:35"
},
{
"name": "ربع سکه",
"price_rial": 535000000,
"price_toman": 53500000,
"change": "(2.88%) 15,000,000",
"min_toman": 52500000,
"max_toman": 53500000,
"last_update": "15:38:23"
},
{
"name": "سکه گرمی",
"price_rial": 275000000,
"price_toman": 27500000,
"change": "(1.85%) 5,000,000",
"min_toman": 27000000,
"max_toman": 27500000,
"last_update": "15:55:20"
}
]
}

1945
panel/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,7 @@ function topbarHtml(active, primaryLabel) {
<nav class="tabs">
<button data-tab="articles" class="${active==='articles'?'tab-active':''}">مطالب</button>
<button data-tab="risks" class="${active==='risks'?'tab-active':''}">ریسکها</button>
<button data-tab="users" class="${active==='users'?'tab-active':''}">کاربران</button>
</nav>
<div class="actions">
<button id="newBtn" class="primary">${primaryLabel}</button>
@ -84,6 +85,7 @@ function wireTabs() {
btn.addEventListener('click', () => {
if (btn.dataset.tab === 'articles') renderList();
else if (btn.dataset.tab === 'risks') renderRisksList();
else if (btn.dataset.tab === 'users') renderUsersList();
});
});
$('#logoutBtn').addEventListener('click', async () => {
@ -432,4 +434,119 @@ function renderRiskEditor(risk) {
});
}
// ---------- users ----------
async function renderUsersList() {
root.innerHTML = `
${topbarHtml('users', '+ کاربر جدید')}
<main class="page">
<h2>کاربران پنل</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری</p></div>
</main>
`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderUserEditor(null));
try {
const items = await api('/api/users');
if (!items.length) {
$('#list').innerHTML = `<p class="muted">هنوز کاربری ثبت نشده است.</p>`;
return;
}
$('#list').innerHTML = items.map(u => `
<article class="row" data-id="${escapeAttr(u.id)}" data-username="${escapeAttr(u.username)}">
<div class="row-main">
<div class="row-meta">
<strong>${escapeHtml(u.username)}</strong>
${u.createdAt ? `<span class="muted">عضویت: ${escapeHtml(u.createdAt)}</span>` : ''}
</div>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>
`).join('');
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const row = btn.closest('.row');
const id = row.dataset.id;
if (btn.dataset.action === 'edit') {
renderUserEditor({ id, username: row.dataset.username });
} else if (btn.dataset.action === 'delete') {
if (!confirm('این کاربر حذف شود؟')) return;
try {
await api(`/api/users/${id}`, { method: 'DELETE' });
renderUsersList();
} catch (err) {
alert('حذف ناموفق بود: ' + friendlyUserError(err.message));
}
}
});
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری کاربران.</p>`;
}
}
function renderUserEditor(user) {
const u = user || { username: '' };
const isEdit = !!user;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش کاربر' : 'کاربر جدید'}</div>
<div class="actions">
<button id="backBtn" class="ghost"> بازگشت</button>
</div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<div class="col span-2">
<label>نام کاربری
<input name="username" value="${escapeAttr(u.username)}" autocomplete="off" required />
</label>
</div>
<div class="col span-2">
<label>رمز عبور ${isEdit ? '(برای تغییر پر کنید، خالی = بدون تغییر)' : '(حداقل ۸ کاراکتر)'}
<input name="password" type="password" autocomplete="new-password" ${isEdit ? '' : 'required'} />
</label>
</div>
<div class="col span-2 row-end">
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد کاربر'}</button>
</div>
</form>
</main>
`;
$('#backBtn').addEventListener('click', () => renderUsersList());
$('#editorForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const username = String(fd.get('username') || '').trim();
const password = String(fd.get('password') || '');
const payload = { username };
if (password) payload.password = password;
try {
if (isEdit) await api(`/api/users/${user.id}`, { method: 'PUT', body: payload });
else await api('/api/users', { method: 'POST', body: { username, password } });
renderUsersList();
} catch (err) {
alert('ذخیره ناموفق بود: ' + friendlyUserError(err.message));
}
});
}
function friendlyUserError(msg) {
const m = String(msg || '');
if (m.includes('username_taken')) return 'این نام کاربری قبلاً ثبت شده است.';
if (m.includes('username_too_short')) return 'نام کاربری باید حداقل ۳ کاراکتر باشد.';
if (m.includes('password_too_short')) return 'رمز عبور باید حداقل ۸ کاراکتر باشد.';
if (m.includes('cannot_delete_self')) return 'نمی‌توانید حساب خودتان را حذف کنید.';
if (m.includes('cannot_delete_last_user')) return 'آخرین کاربر را نمی‌توان حذف کرد.';
return m;
}
api('/api/auth/me').then(() => renderList()).catch(() => renderLogin());

View File

@ -280,6 +280,57 @@ app.delete('/api/risks/:id', authRequired, (req, res) => {
res.status(204).end();
});
// ---------- users (admin management) ----------
// password_hash is NEVER returned to the client.
app.get('/api/users', authRequired, (_req, res) => {
const rows = db.prepare('SELECT id, username, created_at FROM users ORDER BY id').all();
res.json(rows.map((r) => ({ id: r.id, username: r.username, createdAt: r.created_at })));
});
app.post('/api/users', authRequired, (req, res) => {
const username = String(req.body?.username || '').trim();
const password = String(req.body?.password || '');
if (username.length < 3) return res.status(400).json({ error: 'username_too_short' });
if (password.length < 8) return res.status(400).json({ error: 'password_too_short' });
const exists = db.prepare('SELECT 1 FROM users WHERE username = ?').get(username);
if (exists) return res.status(409).json({ error: 'username_taken' });
const hash = bcrypt.hashSync(password, 10);
const info = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(username, hash);
res.status(201).json({ id: info.lastInsertRowid, username });
});
app.put('/api/users/:id', authRequired, (req, res) => {
const id = Number(req.params.id);
const row = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'not_found' });
const username = req.body?.username != null ? String(req.body.username).trim() : row.username;
const password = req.body?.password != null ? String(req.body.password) : '';
if (username.length < 3) return res.status(400).json({ error: 'username_too_short' });
if (username !== row.username) {
const taken = db.prepare('SELECT 1 FROM users WHERE username = ? AND id != ?').get(username, id);
if (taken) return res.status(409).json({ error: 'username_taken' });
}
if (password) {
if (password.length < 8) return res.status(400).json({ error: 'password_too_short' });
const hash = bcrypt.hashSync(password, 10);
db.prepare('UPDATE users SET username = ?, password_hash = ? WHERE id = ?').run(username, hash, id);
} else {
db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username, id);
}
res.json({ id, username });
});
app.delete('/api/users/:id', authRequired, (req, res) => {
const id = Number(req.params.id);
if (req.user?.sub === id) return res.status(400).json({ error: 'cannot_delete_self' });
const count = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
if (count <= 1) return res.status(400).json({ error: 'cannot_delete_last_user' });
const info = db.prepare('DELETE FROM users WHERE id = ?').run(id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- prices (scraped from tgju.org every 2 min) ----------
app.get('/api/prices', (_req, res) => {
const rows = db

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

BIN
public/logo white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

300
scraper/scraper.py Normal file
View File

@ -0,0 +1,300 @@
import os
import sys
import urllib.request
import ssl
import re
import time
import json
from datetime import datetime
# Ensure terminal output supports UTF-8 on Windows
if sys.platform == 'win32':
try:
sys.stdout.reconfigure(encoding='utf-8')
except AttributeError:
pass
# Persian/Arabic to English digit mapping
FA_TO_EN = {
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4',
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9',
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4',
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9'
}
def clean_persian_text(text):
"""Clean HTML tags and standardize spacing."""
if not text:
return ""
text = re.sub(r'<[^>]+>', '', text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def to_english_digits(text):
"""Convert Persian/Arabic digits in a string to English digits."""
if not text:
return ""
return "".join(FA_TO_EN.get(char, char) for char in text)
def parse_numeric(text):
"""Convert a price string with commas and Persian digits into an integer."""
if not text:
return 0
clean_text = to_english_digits(text)
digits_only = re.sub(r'[^\d]', '', clean_text)
try:
return int(digits_only) if digits_only else 0
except ValueError:
return 0
def fetch_html_with_retry(url, retries=3, delay=2):
"""Fetch URL with retries, custom headers, and disabled SSL checks."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'fa,en-US;q=0.9,en;q=0.8',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
# Make it a POST request by passing empty data (b"") to completely bypass CDN/ArvanCloud HTML caching
req = urllib.request.Request(url, data=b"", headers=headers)
for attempt in range(1, retries + 1):
try:
with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
if response.status == 200:
html = response.read().decode('utf-8')
if "sekee" in html:
return html
else:
raise ValueError("Incorrect page content returned (missing expected coin tags).")
except Exception as e:
if attempt < retries:
time.sleep(delay)
delay *= 1.5
else:
print(f"⚠️ Connection error on attempt {attempt}: {e}")
return None
HISTORY_LOG = []
HISTORY_INITIALIZED = False
def initialize_history_from_excel():
global HISTORY_LOG, HISTORY_INITIALIZED
excel_filename = "coin_prices.xlsx"
if not HISTORY_INITIALIZED:
if os.path.exists(excel_filename):
try:
import pandas as pd
df = pd.read_excel(excel_filename, sheet_name="تاریخچه تغییرات")
HISTORY_LOG = df.to_dict(orient="records")
print(f"📥 Loaded {len(HISTORY_LOG)} history logs from existing '{excel_filename}'.")
except Exception as e:
print(f"💡 Initialized new history log in memory (failed to load: {e})")
HISTORY_INITIALIZED = True
def save_live_js_and_json(scraped_data):
"""Save scraped data to JSON and JS files for real-time HTML dashboard."""
current_time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
live_records = []
for row in scraped_data:
live_records.append({
"name": row["Name"],
"price_rial": row["Price_Rial"],
"price_toman": row["Price_Toman"],
"change": row["Change"],
"min_toman": row["Min_Price_Toman"],
"max_toman": row["Max_Price_Toman"],
"last_update": row["Last_Update"]
})
data_to_save = {
"last_fetch": current_time_str,
"prices": live_records
}
# Save as JSON
try:
with open("live_prices.json", "w", encoding="utf-8") as f:
json.dump(data_to_save, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"⚠️ Error saving live_prices.json: {e}")
# Save as JS (for file:// protocol CORS bypass)
try:
js_content = f"window.LIVE_COIN_PRICES = {json.dumps(data_to_save, ensure_ascii=False, indent=2)};"
with open("live_prices.js", "w", encoding="utf-8") as f:
f.write(js_content)
except Exception as e:
print(f"⚠️ Error saving live_prices.js: {e}")
def save_to_excel(scraped_data):
"""Save scraped data to Excel file with Latest Prices and History sheets."""
global HISTORY_LOG
excel_filename = "coin_prices.xlsx"
# Initialize history from Excel if we haven't already
initialize_history_from_excel()
# Create list for current scraped data
new_rows = []
current_time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for row in scraped_data:
new_rows.append({
"زمان ثبت": current_time_str,
"نام سکه": row["Name"],
"قیمت فعلی (ریال)": row["Price_Rial"],
"قیمت فعلی (تومان)": row["Price_Toman"],
"میزان و درصد تغییر": row["Change"],
"کمترین قیمت روز (تومان)": row["Min_Price_Toman"],
"بیشترین قیمت روز (تومان)": row["Max_Price_Toman"],
"زمان به‌روزرسانی سایت": row["Last_Update"]
})
import pandas as pd
df_new = pd.DataFrame(new_rows)
df_latest = df_new.drop(columns=["زمان ثبت"])
# Accumulate history in memory list
temp_history_log = HISTORY_LOG + new_rows
df_history = pd.DataFrame(temp_history_log)
# Write both sheets to Excel
try:
with pd.ExcelWriter(excel_filename, engine='openpyxl') as writer:
df_latest.to_excel(writer, sheet_name="قیمت‌های لحظه‌ای", index=False)
df_history.to_excel(writer, sheet_name="تاریخچه تغییرات", index=False)
# Write succeeded! Commit the temp log to our persistent memory HISTORY_LOG
HISTORY_LOG = temp_history_log
print(f"📊 Excel file updated: '{excel_filename}' (Latest & History sheets)")
return True
except Exception as e:
print(f"⚠️ Warning: Excel file is locked (likely open in Microsoft Excel).")
print(f"⚠️ {len(new_rows)} rows kept in memory and will auto-save on the next minute cycle when you close Excel. (Error: {e})")
return False
def run_scraper_cycle():
"""Runs a single cycle of the scraper."""
url = "https://www.tgju.org/coin"
html = fetch_html_with_retry(url)
if not html:
print("📡 Connection to TGJU failed. Retrying in next cycle...")
return False
market_rows = re.findall(r'<tr[^>]*data-market-row="([^"]+)"[^>]*>([\s\S]*?)<\/tr>', html)
target_keys = {
"sekee": "سکه امامی",
"sekeb": "سکه بهار آزادی",
"nim": "نیم سکه",
"rob": "ربع سکه",
"gerami": "سکه گرمی"
}
scraped_data = []
for row_name, row_content in market_rows:
if row_name in target_keys:
coin_display_name = target_keys[row_name]
tds = re.findall(r'<td[^>]*>([\s\S]*?)<\/td>', row_content)
if len(tds) >= 5:
raw_price_rial = clean_persian_text(tds[0])
raw_change = clean_persian_text(tds[1])
raw_min = clean_persian_text(tds[2])
raw_max = clean_persian_text(tds[3])
raw_time = clean_persian_text(tds[4])
price_rial = parse_numeric(raw_price_rial)
price_toman = price_rial // 10
min_rial = parse_numeric(raw_min)
min_toman = min_rial // 10
max_rial = parse_numeric(raw_max)
max_toman = max_rial // 10
clean_change = to_english_digits(raw_change)
clean_time = to_english_digits(raw_time)
scraped_data.append({
"Key": row_name,
"Name": coin_display_name,
"Price_Rial": price_rial,
"Price_Toman": price_toman,
"Change": clean_change,
"Min_Price_Toman": min_toman,
"Max_Price_Toman": max_toman,
"Last_Update": clean_time
})
if not scraped_data:
print("⚠️ No coin rows were matched in the HTML. Table structure may have changed.")
return False
# Save Excel
try:
save_to_excel(scraped_data)
except Exception as e:
print(f"⚠️ Error preparing Excel: {e}")
# Save JSON and JS for the dynamic HTML dashboard
try:
save_live_js_and_json(scraped_data)
except Exception as e:
print(f"⚠️ Error saving live assets: {e}")
# Print safely to console
print("\n" + "=" * 80)
print(f"{'نام سکه':<20} | {'قیمت (تومان)':<18} | {'تغییر':<18} | {'به‌روزرسانی':<12}")
print("-" * 80)
for row in scraped_data:
name_ascii = row["Name"].ljust(20)
price_str = f"{row['Price_Toman']:,}"
change_str = row["Change"]
time_str = row["Last_Update"]
try:
print(f"{name_ascii} | {price_str:<18} | {change_str:<18} | {time_str:<12}")
except Exception:
print(f"{row['Key'].ljust(20)} | {price_str:<18} | {change_str:<18} | {time_str:<12}")
print("=" * 80 + "\n")
return True
def main():
print("=" * 60)
print(" 🪙 TGJU AUTOMATED COIN SCRAPER & SCHEDULER 🪙")
print(" 🔄 Running every 1 minute | Output: Excel & Realtime HTML")
print("=" * 60)
print(f"🚀 Initializing scraper at {datetime.now().strftime('%H:%M:%S')}...")
run_scraper_cycle()
while True:
try:
for remaining in range(60, 0, -1):
sys.stdout.write(f"\r⏳ Next fetch in {remaining:02d} seconds... ")
sys.stdout.flush()
time.sleep(1)
sys.stdout.write("\r📡 Fetching live prices... \n")
sys.stdout.flush()
run_scraper_cycle()
except KeyboardInterrupt:
print("\n👋 Scraper stopped by user. Goodbye!")
sys.exit(0)
except Exception as e:
print(f"\n⚠️ Unexpected error in loop: {e}")
print("⏳ Retrying in 10 seconds...")
time.sleep(10)
if __name__ == "__main__":
main()

BIN
spread-00.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
spread-450.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

BIN
spread-850.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

View File

@ -0,0 +1,77 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
const NAVY = '#032340'
const GOLD = '#CD9E53'
type Ev = { title: string; desc: string; when: string }
const EVENTS: Ev[] = [
{
title: 'اجلاس جهانی فولاد سبز | استکهلم، سوئد',
desc: 'بررسی آخرین تکنولوژی‌های احیای مستقیم بر پایه هیدروژن و جذب سرمایه برای پروژه‌های بدون کربن. حضور در این رویداد برای درک آینده مقررات زیست‌محیطی اروپا حیاتی است.',
when: 'مهر ۱۴۰۵',
},
{
title: 'نشست سالانه انجمن جهانی فولاد (worldsteel) | شانگهای، چین',
desc: 'گردهمایی بزرگ‌ترین رهبران صنعت. محور اصلی امسال: پیش‌بینی تقاضای جهانی در سایه رکود بخش مسکن چین و تغییر مسیر زنجیره تامین به سمت هند و خاورمیانه.',
when: 'آبان ۱۴۰۵',
},
{
title: 'مجمع بین‌المللی هوش مصنوعی در تولید | برلین، آلمان',
desc: 'رونمایی از جدیدترین نسل دوقلوهای دیجیتال (Digital Twins) و الگوریتم‌های پیش‌بینی تعمیرات. فرصتی برای آشنایی با راهکارهای کاهش هزینه و افزایش بهره‌وری خطوط نورد.',
when: 'آذر ۱۴۰۵',
},
]
export default function StrategicEvents() {
const [open, setOpen] = useState<number | null>(0)
return (
<div style={{ direction: 'rtl' }}>
{/* heading */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 28, justifyContent: 'flex-start' }}>
<div style={{ width: 4, height: 30, background: GOLD, borderRadius: 2, order: 2 }} />
<h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0, order: 1 }}>
دیدهبان رویدادهای راهبردی
</h2>
</div>
{/* accordion list */}
<div>
{EVENTS.map((e, i) => {
const isOpen = open === i
return (
<div key={i} style={{ borderBottom: '1px solid var(--rule-thin)' }}>
<button
onClick={() => setOpen(isOpen ? null : i)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'right',
padding: '18px 0', color: GOLD,
}}
>
<span style={{ fontSize: 'clamp(14px,1.3vw,17px)', fontWeight: 800, lineHeight: 1.5 }}>{e.title}</span>
<ChevronDown
size={20} strokeWidth={2.4} color={GOLD}
style={{ flexShrink: 0, transform: isOpen ? 'rotate(180deg)' : 'none', transition: 'transform 250ms' }}
/>
</button>
{/* expandable body */}
<div style={{
maxHeight: isOpen ? 260 : 0, overflow: 'hidden',
transition: 'max-height 320ms cubic-bezier(0.22,1,0.36,1), opacity 250ms',
opacity: isOpen ? 1 : 0,
}}>
<p style={{ fontSize: 13.5, lineHeight: 2.05, color: 'rgba(3,35,64,0.66)', margin: '0 0 20px', textAlign: 'justify' }}>
{e.desc} <span style={{ color: 'rgba(3,35,64,0.5)' }}>(زمان: {e.when})</span>
</p>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@ -2,6 +2,8 @@ import SpreadFeatures from './sections/SpreadFeatures'
import IntegrationsSection from './sections/IntegrationsSection'
// import InternalNewsSection from './sections/InternalNewsSection' // «تازه‌ترین اخبار صنعت» — disabled, restore anytime
import RadarTechSection from './sections/RadarTechSection'
import AtAGlanceSection from './sections/AtAGlanceSection'
import FeaturedReportBanner from './sections/FeaturedReportBanner'
import MapEventsSection from './sections/MapEventsSection'
import FactoryReportsScroll from './sections/FactoryReportsScroll'
import NewsletterMarquee from './sections/NewsletterMarquee'
@ -16,7 +18,10 @@ export default function Home() {
<div dir={lang === 'fa' ? 'rtl' : 'ltr'}>
<SpreadFeatures />
{/* <InternalNewsSection /> «تازه‌ترین اخبار صنعت» — disabled, restore anytime */}
<RadarTechSection />
<RadarTechSection only="radar" />
<AtAGlanceSection />
<RadarTechSection only="approach" />
<FeaturedReportBanner />
<IntegrationsSection />
<MapEventsSection />
<NewsletterMarquee />

View File

@ -60,27 +60,27 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
}
return (
<section id="at-a-glance" style={{ background: '#faf7f4', padding: '90px 0', overflow: 'hidden', scrollMarginTop: '90px' }} dir={isFa ? 'rtl' : 'ltr'}>
<section id="at-a-glance" style={{ background: '#faf7f4', padding: '52px 0', overflow: 'hidden', scrollMarginTop: '90px' }} dir={isFa ? 'rtl' : 'ltr'}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
{/* heading with gold rule (hidden when embedded in a page that has its own header) */}
{!embedded && (
<div style={{ display: 'flex', alignItems: 'center', gap: 14, justifyContent: 'flex-start', marginBottom: 40 }}>
<h2 style={{ fontSize: 'clamp(26px, 2.6vw, 38px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0, order: isFa ? 1 : 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, justifyContent: 'flex-start', marginBottom: 24 }}>
<h2 style={{ fontSize: 'clamp(22px, 2.2vw, 30px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0, order: isFa ? 1 : 2 }}>
{T.heading}
</h2>
<div style={{ width: 4, height: 34, background: GOLD, borderRadius: 2, order: isFa ? 2 : 1 }} />
<div style={{ width: 4, height: 28, background: GOLD, borderRadius: 2, order: isFa ? 2 : 1 }} />
</div>
)}
{/* image on the LEFT, text on the RIGHT in RTL (matches the reference).
In RTL, the first flex child lands on the right so we use row-reverse to push image left. */}
{/* image on the LEFT, text on the RIGHT (matches the reference).
Container is LTR; image is the first DOM child, so plain `row` lands it on the left. */}
<div
style={{ display: 'flex', flexDirection: isFa ? 'row-reverse' : 'row', alignItems: 'center', gap: 0, minHeight: 480, direction: 'ltr' }}
style={{ display: 'flex', flexDirection: isFa ? 'row' : 'row-reverse', alignItems: 'center', gap: 0, minHeight: 380, direction: 'ltr' }}
className="max-lg:!flex-col max-lg:!gap-8"
>
{/* ── IMAGE side (with stacked-card shadow) ── */}
<div style={{ width: '38%', position: 'relative', flexShrink: 0 }} className="max-lg:!w-full max-lg:!max-w-[420px]">
<div style={{ width: '35%', position: 'relative', flexShrink: 0 }} className="max-lg:!w-full max-lg:!max-w-[400px]">
{/* back card (offset, behind) — offset away from the text card */}
<div style={{
position: 'absolute', inset: 0,
@ -91,7 +91,7 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
{/* front image — portrait on desktop, shorter landscape on mobile */}
<div
style={{ position: 'relative', borderRadius: 18, overflow: 'hidden', boxShadow: '0 40px 90px -30px rgba(7,29,73,0.45)' }}
className="aspect-[3/3.6] max-lg:!aspect-[16/10]"
className="aspect-[3/3.2] max-lg:!aspect-[16/10]"
>
<AnimatePresence mode="wait" custom={dir}>
<motion.img

View File

@ -0,0 +1,99 @@
import { Download } from 'lucide-react'
import { useLang } from '@/context/LangContext'
const NAVY = '#032340'
const GOLD = '#CD9E53'
const BOOK = '/Horizontal_Book_Mockup_6%201.webp'
const T = {
fa: {
overline: 'ماهنامه شماره ۱۲ منتشر شد…',
title: 'دیپلماسی معدنی در عصر انسدادهای ژئوپلیتیک',
subtitle: 'Mineral Diplomacy in the Era of Geopolitical Blockades',
cta: 'دانلود گزارش',
},
en: {
overline: 'Monthly issue #12 is out…',
title: 'Mineral Diplomacy in the Era of Geopolitical Blockades',
subtitle: 'دیپلماسی معدنی در عصر انسدادهای ژئوپلیتیک',
cta: 'Download report',
},
}
export default function FeaturedReportBanner() {
const { lang } = useLang()
const isFa = lang === 'fa'
const t = T[lang]
// no PDF yet — button present but downloads nothing
const onDownload = () => { /* TODO: wire real PDF when available */ }
return (
<section style={{ background: 'var(--paper)', padding: '48px 0 72px' }} dir={isFa ? 'rtl' : 'ltr'}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
{/* wrapper allows the book to spill outside the frame */}
<div style={{ position: 'relative', overflow: 'visible' }}>
{/* clipped background (navy gradient + angled gold band) */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 20, overflow: 'hidden',
background: `linear-gradient(110deg, ${NAVY} 0%, #06335c 55%, ${NAVY} 100%)`,
boxShadow: '0 30px 70px -34px rgba(3,35,64,0.5)',
}}>
<div style={{
position: 'absolute', insetInlineStart: 0, insetInlineEnd: 0, bottom: -6, height: 88,
background: GOLD, transform: 'skewY(-2.4deg)', transformOrigin: isFa ? 'right' : 'left',
}} className="max-md:hidden" />
</div>
{/* content: text on the LEFT, book on the RIGHT (text is first DOM child, plain row) */}
<div style={{ position: 'relative', display: 'flex', flexDirection: 'row', alignItems: 'center', minHeight: 240, direction: 'ltr' }}
className="max-md:!flex-col">
{/* TEXT — left */}
<div style={{ width: '52%', padding: '40px 48px', direction: isFa ? 'rtl' : 'ltr' }}
className="max-md:!w-full max-md:!px-6 max-md:!pt-8 max-md:!pb-6 max-md:!text-center">
<div style={{ fontSize: 13, fontWeight: 700, color: 'rgba(255,255,255,0.55)', marginBottom: 14 }}>{t.overline}</div>
<h2 style={{ fontSize: 'clamp(20px, 2.3vw, 30px)', fontWeight: 900, color: '#fff', lineHeight: 1.4, letterSpacing: '-0.5px', margin: '0 0 8px' }}>
{t.title}
</h2>
<div style={{ fontSize: 'clamp(12px, 1.1vw, 15px)', color: 'rgba(255,255,255,0.58)', marginBottom: 24, direction: 'ltr', textAlign: isFa ? 'right' : 'left' }} className="max-md:!text-center">
{t.subtitle}
</div>
{/* button BELOW the text */}
<button
onClick={onDownload}
style={{
display: 'inline-flex', alignItems: 'center', gap: 10,
background: '#f4ede0', color: NAVY, border: 'none', borderRadius: 10,
padding: '13px 28px', fontSize: 14, fontWeight: 800, cursor: 'pointer', fontFamily: 'inherit',
transition: 'transform 180ms, box-shadow 180ms',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'; (e.currentTarget as HTMLElement).style.boxShadow = '0 12px 26px -10px rgba(0,0,0,0.4)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; (e.currentTarget as HTMLElement).style.boxShadow = 'none' }}
>
<Download size={17} strokeWidth={2.4} />
{t.cta}
</button>
</div>
{/* BOOK — right, spilling out of the frame */}
<div style={{ width: '48%', position: 'relative', alignSelf: 'stretch' }} className="max-md:!w-full">
<img
src={BOOK}
alt={t.title}
style={{
position: 'absolute', insetInlineEnd: '-2%', top: '50%', transform: 'translateY(-50%)',
width: '116%', maxWidth: 'none', height: 'auto',
filter: 'drop-shadow(0 30px 46px rgba(0,0,0,0.5))',
}}
className="max-md:!relative max-md:!w-full max-md:!translate-y-0 max-md:!top-0 max-md:!end-0 max-md:!p-4"
/>
</div>
</div>
</div>
</div>
</section>
)
}

View File

@ -1,52 +1,18 @@
import { Link } from 'react-router-dom'
import Globe from '@/components/ui/globe'
import EventCalendar from '@/components/ui/EventCalendar'
import StrategicEvents from '@/components/ui/StrategicEvents'
import { useLang } from '@/context/LangContext'
const T = {
fa: {
mapOverline: 'اسکنر جهانی',
mapHeading: 'تمام رویدادهای فولادی دنیا را رصد می‌کنیم',
mapSub: 'از بورس دالیان چین تا گمرک بصره عراق — هر سیگنالی که بر رقابت‌پذیری ایران اثر می‌گذارد.',
eventsOverline: 'تقویم رویداد', eventsHeading: 'رویدادهای پیش‌رو',
eventsAll: 'تقویم کامل ←', register: 'ثبت‌نام ←', soon: 'به‌زودی',
},
en: {
mapOverline: 'GLOBAL SCANNER',
mapHeading: 'Every Global Steel Event, Monitored',
mapSub: 'From the Dalian Commodity Exchange to the Basra customs gate — every signal affecting Iran\'s competitiveness.',
eventsOverline: 'EVENT CALENDAR', eventsHeading: 'Upcoming Events',
eventsAll: 'Full Calendar →', register: 'Register →', soon: 'Coming Soon',
},
}
export default function MapEventsSection() {
const { lang } = useLang()
const t = T[lang]
return (
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', borderBottom: '1px solid var(--rule-thin)', overflow: 'hidden' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
<div style={{ display: 'flex', alignItems: 'stretch', minHeight: 520 }} className="max-md:flex-col">
{/* ── Text + Calendar — RIGHT in RTL ── */}
<div style={{ flex: '4 4 0', minWidth: 0, display: 'flex', flexDirection: 'column' }}>
{/* scanner heading (moved here from above the globe) */}
<div style={{ padding: '48px 20px 4px' }}>
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 8 }}>{t.mapOverline}</div>
<h2 style={{ fontSize: 'clamp(18px,2vw,26px)', fontWeight: 900, color: 'var(--ink)', letterSpacing: '-0.5px', lineHeight: 1.2, marginBottom: 10 }}>{t.mapHeading}</h2>
<p style={{ fontSize: 13, color: 'var(--ink-4)', lineHeight: 1.7 }}>{t.mapSub}</p>
</div>
{/* upcoming-events heading */}
<div style={{ padding: '20px 20px 12px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }}>
<h3 style={{ fontSize: 'clamp(15px,1.4vw,20px)', fontWeight: 900, color: 'var(--ink)', letterSpacing: '-0.3px', margin: 0 }}>{t.eventsHeading}</h3>
<Link to="/events" style={{ fontSize: 11, fontWeight: 700, color: 'var(--red)', textDecoration: 'none', whiteSpace: 'nowrap', flexShrink: 0 }}>{t.eventsAll}</Link>
</div>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '8px 20px 40px' }}>
<EventCalendar />
</div>
{/* ── Strategic events — RIGHT in RTL ── */}
<div style={{ flex: '4 4 0', minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '48px 24px' }} className="max-md:!px-5">
<StrategicEvents />
</div>
{/* ── Globe — LEFT, no text ── */}

View File

@ -40,15 +40,18 @@ const T = {
},
}
export default function RadarTechSection() {
export default function RadarTechSection({ only }: { only?: 'radar' | 'approach' }) {
const { lang } = useLang()
const t = T[lang]
const showRadar = only !== 'approach'
const showApproach = only !== 'radar'
return (
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper-2)' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
{/* ══ رادار آینده ══ */}
{showRadar && (
<div style={{ paddingTop: 64, paddingBottom: 56 }}>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 32, flexWrap: 'wrap', gap: 12 }}>
<div>
@ -65,11 +68,13 @@ export default function RadarTechSection() {
))}
</div>
</div>
)}
{/* divider */}
<div style={{ height: 1, background: 'var(--rule-thin)' }} />
{/* divider — only when both halves render together */}
{showRadar && showApproach && <div style={{ height: 1, background: 'var(--rule-thin)' }} />}
{/* ══ حوزه‌های پژوهشی ══ */}
{showApproach && (
<div style={{ paddingTop: 56, paddingBottom: 64 }}>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 32, flexWrap: 'wrap', gap: 12 }}>
<div>
@ -108,6 +113,7 @@ export default function RadarTechSection() {
})}
</div>
</div>
)}
</div>
</section>

View File

@ -1,51 +1,72 @@
import { useState } from 'react'
import { useLang } from '@/context/LangContext'
const NAVY = '#032340'
const GOLD = '#CD9E53'
const HASHTAGS = ['#فولاد _ آینده', '#فولاد _ آینده', '#فولاد _ آینده']
const VIDEOS = [
{
id: 1,
fa: { title: 'چشم‌انداز فولاد ایران در ۱۴۰۴', desc: 'بررسی روندهای تولید، صادرات و سیاست‌گذاری صنعت فولاد ایران در سال پیش رو' },
en: { title: 'Iran Steel Outlook 1404', desc: 'Production trends, exports and policy analysis for Iran\'s steel industry' },
ep: 'EP.12', duration: '۴۲ دقیقه', thumb: '/news/photo-1504307651254-35680f356dfd.jpg',
fa: { title: 'نگاهی به آن سوی مرزهای تولید', guest: 'گفتگو با مهندس سیامک فجری',
body: 'در ویدیوکست «Steel Horizon»، ما فراتر از داده‌های خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت می‌رویم. اینجا محل تلاقی استراتژی‌های کلان، تکنولوژی‌های برهم‌زننده و رهبرانی است که فولاد فردا را نه با کوره‌ها، که با «بینش» می‌سازند. ما طوفان‌های بازار را تحلیل می‌کنیم تا شما با اطمینان، مسیر آینده را ترسیم کنید.\nصنعت فولاد در حال ورود به پیچیده‌ترین چرخه حیات خود است؛ جایی که هوش مصنوعی، مقررات کربنی و ژئواکونومی، قواعد بازی را بازنویسی کرده‌اند. در «Steel Logic» با تحلیل‌گران ارشد و استراتژیست‌های تراز اول، به گفتگو می‌نشینیم تا لایه‌های پنهان این پیچیدگی‌ها را آشکار کنیم. اینجا خبری از هیجان‌زدگی نیست؛ فقط تحلیل‌های داده‌محور و تصمیم‌ساز.' },
en: { title: 'Beyond the Frontiers of Production', guest: 'A talk with Eng. Siamak Fajri',
body: 'In the "Steel Horizon" videocast we go beyond raw price and tonnage data to the engineering of change and the architecture of the industry\'s future.' },
ep: 'EP.12', duration: '۴۲ دقیقه', date: '۲۵ خرداد ۱۴۰۵', thumb: '/news/photo-1504307651254-35680f356dfd.jpg',
},
{
id: 2,
fa: { title: 'تحلیل بازار جهانی فلزات پایه', desc: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره فولاد' },
en: { title: 'Global Base Metals Analysis', desc: 'Price volatility in copper, aluminum and zinc and its steel chain impact' },
ep: 'EP.11', duration: '۳۸ دقیقه', thumb: '/news/photo-1558618666-fcd25c85cd64.jpg',
fa: { title: 'تحلیل بازار جهانی فلزات پایه', guest: 'گفتگو با کارشناسان بازار',
body: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره فولاد را با تحلیل‌گران بازار بررسی می‌کنیم.' },
en: { title: 'Global Base Metals Analysis', guest: 'With market analysts',
body: 'Price volatility in copper, aluminum and zinc and its steel-chain impact.' },
ep: 'EP.11', duration: '۳۸ دقیقه', date: '۱۸ خرداد ۱۴۰۵', thumb: '/news/photo-1558618666-fcd25c85cd64.jpg',
},
{
id: 3,
fa: { title: 'ژئوپلیتیک و صادرات فولاد', desc: 'تأثیر تحولات منطقه‌ای بر بازارهای صادراتی فولاد ایران و فرصت‌های پیش رو' },
en: { title: 'Geopolitics & Steel Exports', desc: 'How regional developments shape Iran\'s steel export markets' },
ep: 'EP.10', duration: '۵۱ دقیقه', thumb: '/news/photo-1504328345606-18bbc8c9d7d1.jpg',
fa: { title: 'ژئوپلیتیک و صادرات فولاد', guest: 'گفتگو با تحلیل‌گران ژئواکونومی',
body: 'تأثیر تحولات منطقه‌ای بر بازارهای صادراتی فولاد ایران و فرصت‌های پیش رو.' },
en: { title: 'Geopolitics & Steel Exports', guest: 'With geo-economics analysts',
body: 'How regional developments shape Iran\'s steel export markets.' },
ep: 'EP.10', duration: '۵۱ دقیقه', date: '۱۱ خرداد ۱۴۰۵', thumb: '/news/photo-1504328345606-18bbc8c9d7d1.jpg',
},
{
id: 4,
fa: { title: 'سرمایه‌گذاری در صنعت فولاد', desc: 'فرصت‌ها و چالش‌های سرمایه‌گذاری در بخش‌های مختلف زنجیره ارزش فولاد' },
en: { title: 'Steel Industry Investment', desc: 'Opportunities and challenges across the steel value chain' },
ep: 'EP.09', duration: '۴۵ دقیقه', thumb: '/news/photo-1611273426858-450d8e3c9fce.jpg',
fa: { title: 'سرمایه‌گذاری در صنعت فولاد', guest: 'گفتگو با مدیران سرمایه‌گذاری',
body: 'فرصت‌ها و چالش‌های سرمایه‌گذاری در بخش‌های مختلف زنجیره ارزش فولاد.' },
en: { title: 'Steel Industry Investment', guest: 'With investment managers',
body: 'Opportunities and challenges across the steel value chain.' },
ep: 'EP.09', duration: '۴۵ دقیقه', date: '۴ خرداد ۱۴۰۵', thumb: '/news/photo-1611273426858-450d8e3c9fce.jpg',
},
]
const POD_HASHTAGS = ['#هوش _ مصنوعی', '#عدم _ قطعیت']
const PODCASTS = [
{
id: 1,
fa: { title: 'نبض صنعت — فصل سوم', ep: 'قسمت ۱۲', desc: 'گفتگو با مدیران ارشد صنعت فولاد درباره چالش‌های تامین مالی و توسعه ظرفیت' },
en: { title: 'Industry Pulse — Season 3', ep: 'Episode 12', desc: 'Steel executives on financing challenges and capacity expansion' },
duration: '۵۸ دقیقه', cover: '/news/photo-1540575467063-178a50c2df87.jpg',
epNum: 'اپیزود ۷۷', date: '۲۵ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱۰:۵۶', cover: '/news/photo-1540575467063-178a50c2df87.jpg',
fa: { title: 'کالبدشکافی عقلانی پیچیدگی‌های صنعتی', ep: 'قسمت ۱۲', guest: 'مهندس سیامک شجاعی',
body: '«صنعت فولاد در عصر حاضر، در پیچیده‌ترین چرخه حیات خود قرار گرفته است؛ جایی که همگرایی هوش مصنوعی، الزامات زیست‌محیطی و تنش‌های ژئواکونومیک، قواعد کلاسیک تولید و تجارت را به‌کلی بازنویسی کرده‌اند. در پادکست «نام انتخابی»، ما با عبور از هیاهوی رسانه‌ای و تحلیل‌های سطحی، به کالبدشکافی عقلانی این پیچیدگی‌ها می‌پردازیم. ما با دعوت از استراتژیست‌های تراز اول و خبرگان این حوزه، لایه‌های پنهان چالش‌های صنعتی را می‌شکافیم تا بینشی شفاف، داده‌محور و تصمیم‌ساز را در اختیار رهبری قرار دهیم که برای عبور از طوفان‌های تغییر، به قطب‌نمای دقیق‌تر از عرف‌های بازار نیاز دارند.»' },
en: { title: 'A Rational Dissection of Industrial Complexity', ep: 'Episode 12', guest: 'Eng. Siamak Shojaei',
body: 'Steel today is in its most complex cycle yet — where AI, environmental mandates and geo-economic tension rewrite the classic rules of production and trade.' },
},
{
id: 2,
fa: { title: 'صدای بازار', ep: 'قسمت ۸', desc: 'تحلیل هفتگی قیمت‌های شمش، میلگرد و مقاطع با کارشناسان بازار' },
en: { title: 'Market Voice', ep: 'Episode 8', desc: 'Weekly analysis of billet, rebar and section prices with market experts' },
duration: '۳۲ دقیقه', cover: '/news/photo-1566873535350-6586b0b7a1f2.jpg',
epNum: 'اپیزود ۷۶', date: '۱۸ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱:۴۰:۱۸', cover: '/news/photo-1566873535350-6586b0b7a1f2.jpg',
fa: { title: 'آزمایشگاه کوره', ep: 'قسمت ۸', guest: 'کارشناسان بازار',
body: '«The Foundry Lab» محلی برای کالبدشکافی دقیق تحولات فناورانه و مدل‌های نوین فولادسازی است. ما در این پادکست با نگاهی موشکافانه، چالش‌های فنی را بررسی می‌کنیم.' },
en: { title: 'The Foundry Lab', ep: 'Episode 8', guest: 'Market experts',
body: 'The Foundry Lab dissects technological change and new steelmaking models with a meticulous engineering lens.' },
},
{
id: 3,
fa: { title: 'آینده سبز فولاد', ep: 'قسمت ۵', desc: 'مسیر صنعت فولاد ایران به سمت کاهش کربن و پایداری' },
en: { title: 'Green Steel Future', ep: 'Episode 5', desc: 'Iran\'s steel sector path toward carbon reduction and sustainability' },
duration: '۴۴ دقیقه', cover: '/news/photo-1578662996442-48f60103fc96.jpg',
epNum: 'اپیزود ۷۵', date: '۱۱ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱:۴۰:۱۸', cover: '/news/photo-1578662996442-48f60103fc96.jpg',
fa: { title: 'معمار فولاد', ep: 'قسمت ۵', guest: 'رهبران صنعت',
body: 'در «Steel Architect»، ما به سراغ داستان ساختن فولاد فردا می‌رویم؛ روایتی که رهبرانی که از مدل‌های سنتی کسب‌وکار به چالش کشیده‌اند را به تصویر می‌کشد.' },
en: { title: 'Steel Architect', ep: 'Episode 5', guest: 'Industry leaders',
body: 'Steel Architect tells the story of building tomorrow\'s steel — leaders challenging traditional business models.' },
},
]
@ -79,168 +100,216 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
<div style={{ display: 'grid', gridTemplateColumns: gridCols }} className="max-lg:!grid-cols-1">
{/* ── VIDEOCAST 70% ── */}
{/* ── VIDEOCAST (full-width featured player + playlist + description) ── */}
{showVideo && (
<div style={{ padding: '48px 0 80px 40px', display: 'flex', flexDirection: 'column' }} className="max-md:!p-5">
<div style={{ padding: '56px 0 72px', display: 'flex', flexDirection: 'column', gridColumn: '1 / -1' }} className="max-md:!p-5">
{/* heading */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 28, flexWrap: 'wrap', gap: 12 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<div style={{ width: 28, height: 2, background: 'var(--red)' }} />
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', color: 'var(--ink-4)' }}>VIDEOCAST</span>
</div>
<h2 style={{ fontSize: 22, fontWeight: 900, letterSpacing: '-0.5px', color: 'var(--ink)', margin: 0 }}>{t.videoLabel}</h2>
<p style={{ fontSize: 12, color: 'var(--ink-4)', margin: '4px 0 0' }}>{t.videoSub}</p>
</div>
<button style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink)', background: 'none', border: '1px solid var(--ink)', padding: '6px 16px', cursor: 'pointer', fontFamily: 'inherit' }}>
{t.watchAll}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24, justifyContent: 'flex-start' }}>
<div style={{ width: 4, height: 28, background: GOLD, borderRadius: 2, order: lang === 'fa' ? 2 : 1 }} />
<h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, letterSpacing: '-0.5px', color: NAVY, margin: 0, order: lang === 'fa' ? 1 : 2 }}>{t.videoLabel}</h2>
</div>
{/* featured */}
<div style={{ marginBottom: 16, position: 'relative', overflow: 'hidden', background: '#000', flex: 1, minHeight: 440, borderRadius: 14 }} className="max-md:!min-h-[260px]">
<img src={VIDEOS[activeVideo].thumb} alt="" style={{ width: '100%', height: '100%', minHeight: 440, objectFit: 'cover', display: 'block', opacity: 0.82, position: 'absolute', inset: 0 }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(7,29,73,0.88) 0%, transparent 55%)' }} />
{/* play */}
<div style={{
{/* featured player */}
<div style={{ position: 'relative', overflow: 'hidden', background: '#000', borderRadius: 16, aspectRatio: '16/9', marginBottom: 36, boxShadow: '0 30px 70px -34px rgba(3,35,64,0.5)' }} className="max-md:!aspect-[4/3]">
<img src={VIDEOS[activeVideo].thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', opacity: 0.9, position: 'absolute', inset: 0 }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,35,64,0.55) 0%, transparent 45%)' }} />
{/* center play */}
<button aria-label="play" style={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
width: 54, height: 54, borderRadius: '50%',
background: 'rgba(255,255,255,0.12)', backdropFilter: 'blur(4px)',
border: '2px solid rgba(255,255,255,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
width: 64, height: 64, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.6)',
background: 'rgba(255,255,255,0.14)', backdropFilter: 'blur(4px)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 0, height: 0, borderTop: '10px solid transparent', borderBottom: '10px solid transparent', borderLeft: '18px solid #fff', marginLeft: 4 }} />
</div>
{/* meta */}
<div style={{ position: 'absolute', bottom: 0, right: 0, left: 0, padding: '16px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 9, fontWeight: 700, background: 'var(--red)', color: '#fff', padding: '2px 8px', letterSpacing: '1px' }}>{VIDEOS[activeVideo].ep}</span>
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.55)' }}>{VIDEOS[activeVideo].duration}</span>
<div style={{ width: 0, height: 0, borderTop: '11px solid transparent', borderBottom: '11px solid transparent', borderLeft: '19px solid #fff', marginLeft: 4 }} />
</button>
{/* controls bar */}
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 16px 14px', direction: 'ltr' }}>
<div style={{ height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.3)', marginBottom: 12 }}>
<div style={{ width: '22%', height: '100%', borderRadius: 2, background: GOLD, position: 'relative' }}>
<span style={{ position: 'absolute', right: -5, top: '50%', transform: 'translateY(-50%)', width: 11, height: 11, borderRadius: '50%', background: '#fff' }} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: '#fff' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span style={{ fontSize: 18 }}></span>
<span style={{ fontSize: 15, opacity: 0.85 }}></span>
<span style={{ fontSize: 15, opacity: 0.85 }}>🔊</span>
<span style={{ fontSize: 11, opacity: 0.8, direction: 'ltr' }}>۰۹:۲۷ / {VIDEOS[activeVideo].duration}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, opacity: 0.85 }}>
<span style={{ fontSize: 14 }}></span>
<span style={{ fontSize: 14 }}></span>
</div>
</div>
<h3 style={{ fontSize: 17, fontWeight: 900, color: '#fff', margin: '0 0 5px', lineHeight: 1.3 }}>{VIDEOS[activeVideo][lang].title}</h3>
<p style={{ fontSize: 11, color: 'rgba(255,255,255,0.6)', margin: 0, lineHeight: 1.65 }}>{VIDEOS[activeVideo][lang].desc}</p>
</div>
</div>
{/* episode cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12 }} className="max-sm:!grid-cols-2">
{VIDEOS.map((v, i) => (
<button key={v.id} onClick={() => setActiveVideo(i)} style={{
cursor: 'pointer', padding: 0, background: 'none', textAlign: lang === 'fa' ? 'right' : 'left',
border: i === activeVideo ? '2px solid var(--ink)' : '2px solid var(--rule-thin)',
transition: 'border-color 150ms', display: 'flex', flexDirection: 'column',
}}>
<div style={{ position: 'relative', overflow: 'hidden' }}>
<img src={v.thumb} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block', opacity: i === activeVideo ? 1 : 0.6, transition: 'opacity 150ms' }} />
<span style={{ position: 'absolute', top: 6, right: 6, fontSize: 9, fontWeight: 700, color: '#fff', background: i === activeVideo ? 'var(--red)' : 'rgba(0,0,0,0.65)', padding: '2px 6px', letterSpacing: '0.5px' }}>{v.ep}</span>
</div>
<div style={{ padding: '8px 10px 10px' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink)', lineHeight: 1.35, marginBottom: 4, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{v[lang].title}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-5)' }}>{v.duration}</div>
</div>
</button>
))}
{/* playlist (right) + description (left) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.2fr', gap: 40 }} className="max-lg:!grid-cols-1 max-lg:!gap-8">
{/* playlist — first DOM child → right in RTL */}
<div style={{ order: lang === 'fa' ? 2 : 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, justifyContent: 'flex-end' }}>
<span style={{ fontSize: 14, fontWeight: 900, color: NAVY }}>پلی لیست</span>
<span style={{ fontSize: 16 }}>🎬</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{VIDEOS.map((v, i) => (
<button key={v.id} onClick={() => setActiveVideo(i)} style={{
display: 'flex', alignItems: 'center', gap: 14, width: '100%', cursor: 'pointer',
background: i === activeVideo ? '#fff' : 'transparent', border: 'none', borderRadius: 12, padding: 8,
boxShadow: i === activeVideo ? '0 12px 30px -18px rgba(3,35,64,0.3)' : 'none',
textAlign: lang === 'fa' ? 'right' : 'left', flexDirection: lang === 'fa' ? 'row' : 'row-reverse',
}}>
<div style={{ position: 'relative', flexShrink: 0, width: 96, height: 64, borderRadius: 10, overflow: 'hidden' }}>
<img src={v.thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ width: 26, height: 26, borderRadius: '50%', background: 'rgba(255,255,255,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${GOLD}`, marginLeft: 2 }} />
</span>
</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 700, color: GOLD, lineHeight: 1.5, marginBottom: 5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{v[lang].title}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-5)' }}>{i === activeVideo ? v[lang].guest : v.date}</div>
</div>
</button>
))}
</div>
</div>
{/* description — second DOM child → left in RTL */}
<div style={{ order: lang === 'fa' ? 1 : 2, direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, justifyContent: 'flex-start' }}>
<span style={{ fontSize: 16 }}>🎙</span>
<h3 style={{ fontSize: 'clamp(16px,1.6vw,22px)', fontWeight: 900, color: NAVY, margin: 0, lineHeight: 1.4 }}>{VIDEOS[activeVideo][lang].title}</h3>
</div>
<p style={{ fontSize: 13.5, lineHeight: 2.1, color: 'rgba(3,35,64,0.72)', margin: '0 0 18px', whiteSpace: 'pre-line', textAlign: 'justify' }}>
{VIDEOS[activeVideo][lang].body}
</p>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
{HASHTAGS.map((h, i) => (
<span key={i} style={{ fontSize: 12, fontWeight: 700, color: GOLD, direction: 'rtl' }}>{h}</span>
))}
</div>
</div>
</div>
</div>
)}
{/* ── PODCAST (full width) ── */}
{showPod && (
<div style={{ padding: '48px 40px 80px', background: 'var(--paper)' }} className="max-md:!p-5">
<div style={{ padding: '56px 0 72px', background: 'var(--paper)', gridColumn: '1 / -1' }} className="max-md:!px-5">
<div className="max-w-7xl mx-auto px-12 max-md:!px-0">
{/* heading */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 28, flexWrap: 'wrap', gap: 12 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<div style={{ width: 28, height: 2, background: 'var(--red)' }} />
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', color: 'var(--ink-4)' }}>PODCAST</span>
</div>
<h2 style={{ fontSize: 22, fontWeight: 900, letterSpacing: '-0.5px', color: 'var(--ink)', margin: 0 }}>{t.podLabel}</h2>
<p style={{ fontSize: 12, color: 'var(--ink-4)', margin: '4px 0 0' }}>{t.podSub}</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24, gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 28, background: GOLD, borderRadius: 2, order: lang === 'fa' ? 2 : 1 }} />
<h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, letterSpacing: '-0.5px', color: NAVY, margin: 0, order: lang === 'fa' ? 1 : 2 }}>{t.podLabel}</h2>
</div>
<button style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink)', background: 'none', border: '1px solid var(--ink)', padding: '6px 16px', cursor: 'pointer', fontFamily: 'inherit' }}>
{t.listenAll}
<button style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, fontWeight: 700, color: GOLD, background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
{t.listenAll}
</button>
</div>
{/* featured player (start) + episode list (end) */}
<div style={{ display: 'grid', gridTemplateColumns: '380px 1fr', gap: 28, alignItems: 'start' }} className="max-lg:!grid-cols-1">
{/* ── featured episode player card ── */}
<div style={{
background: 'var(--ink)', borderRadius: 20, padding: 28, color: '#fff',
boxShadow: '0 30px 70px -30px rgba(7,29,73,0.5)', position: 'relative', overflow: 'hidden',
}}>
{/* soft glow */}
<div style={{ position: 'absolute', top: -60, insetInlineEnd: -60, width: 200, height: 200, borderRadius: '50%', background: 'radial-gradient(circle, rgba(0,174,239,0.25), transparent 70%)', pointerEvents: 'none' }} />
<div style={{ position: 'relative', display: 'flex', gap: 16, alignItems: 'center', marginBottom: 22 }}>
<img src={pod.cover} alt="" style={{ width: 84, height: 84, borderRadius: 16, objectFit: 'cover', flexShrink: 0, boxShadow: '0 12px 30px -10px rgba(0,0,0,0.5)' }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '2px', color: '#00AEEF', marginBottom: 6 }}>{pod[lang].ep}</div>
<h3 style={{ fontSize: 16, fontWeight: 900, color: '#fff', margin: 0, lineHeight: 1.4 }}>{pod[lang].title}</h3>
</div>
</div>
<p style={{ position: 'relative', fontSize: 12, color: 'rgba(255,255,255,0.6)', lineHeight: 1.9, margin: '0 0 22px' }}>{pod[lang].desc}</p>
{/* progress */}
<div style={{ position: 'relative', marginBottom: 18 }}>
<div style={{ height: 4, background: 'rgba(255,255,255,0.15)', borderRadius: 2, marginBottom: 7 }}>
<div style={{ width: '35%', height: '100%', background: '#00AEEF', borderRadius: 2 }} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'rgba(255,255,255,0.5)' }}>
<span>۲۰:۱۲</span><span>{pod.duration}</span>
</div>
</div>
{/* controls */}
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 22 }}>
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: 18 }}></span>
<button aria-label="play" style={{
width: 52, height: 52, borderRadius: '50%', border: 'none', cursor: 'pointer',
background: '#00AEEF', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 10px 24px -8px rgba(0,174,239,0.7)',
}}>
<div style={{ width: 0, height: 0, borderTop: '9px solid transparent', borderBottom: '9px solid transparent', borderLeft: '15px solid #fff', marginLeft: 3 }} />
</button>
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: 18 }}></span>
{/* ── featured player card (light) ── */}
<div style={{
display: 'flex', flexDirection: lang === 'fa' ? 'row' : 'row-reverse', gap: 22, alignItems: 'stretch',
background: '#efe9e1', borderRadius: 18, padding: 22, marginBottom: 32, direction: 'ltr',
}} className="max-md:!flex-col">
{/* cover + rating — first DOM child → right in RTL */}
<div style={{ flexShrink: 0, width: 150 }} className="max-md:!w-full max-md:!self-center max-md:!max-w-[200px]">
<img src={pod.cover} alt="" style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', borderRadius: 12, display: 'block', marginBottom: 8 }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<span style={{ color: GOLD, fontSize: 13, letterSpacing: 1 }}></span>
<span style={{ fontSize: 11, fontWeight: 700, color: NAVY }}>{pod.rating}</span>
</div>
</div>
{/* ── episode list ── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* player body — left */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', direction: 'rtl' }}>
{/* top icons + episode + title */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8 }}>
<span style={{ width: 34, height: 34, borderRadius: 9, background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15, color: NAVY }}></span>
<span style={{ width: 34, height: 34, borderRadius: 9, background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15, color: NAVY }}>🔖</span>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 800, color: GOLD, marginBottom: 4 }}>{pod.epNum}</div>
<h3 style={{ fontSize: 'clamp(15px,1.6vw,20px)', fontWeight: 900, color: NAVY, margin: 0, lineHeight: 1.4 }}>{pod[lang].title}</h3>
</div>
</div>
{/* play + date */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<button aria-label="play" style={{
flexShrink: 0, width: 38, height: 38, borderRadius: '50%', border: 'none', cursor: 'pointer',
background: GOLD, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 0, height: 0, borderTop: '7px solid transparent', borderBottom: '7px solid transparent', borderRight: '11px solid #fff', marginRight: 2 }} />
</button>
<div style={{ flex: 1, height: 1, background: 'rgba(3,35,64,0.15)' }} />
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'rgba(3,35,64,0.6)' }}>
{pod.date} <span style={{ color: GOLD }}>📅</span>
</div>
</div>
{/* waveform + times */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, direction: 'ltr' }}>
<span style={{ fontSize: 10, color: 'rgba(3,35,64,0.55)', flexShrink: 0 }}>۰۲:۳۱</span>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 2, height: 26, overflow: 'hidden' }}>
{Array.from({ length: 64 }).map((_, i) => {
const h = 5 + Math.abs(Math.sin(i * 1.7)) * 18
return <span key={i} style={{ flex: 1, height: h, borderRadius: 1, background: i < 14 ? GOLD : 'rgba(3,35,64,0.22)' }} />
})}
</div>
<span style={{ fontSize: 10, color: 'rgba(3,35,64,0.55)', flexShrink: 0 }}>{pod.duration}</span>
</div>
</div>
</div>
{/* ── episode list (left) + description (right) ── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.2fr', gap: 40 }} className="max-lg:!grid-cols-1 max-lg:!gap-8">
{/* episode list — first DOM → right in RTL */}
<div style={{ order: lang === 'fa' ? 2 : 1, display: 'flex', flexDirection: 'column', gap: 14 }}>
{PODCASTS.map((p, i) => (
<button key={p.id} onClick={() => setActivePod(i)} style={{
display: 'flex', alignItems: 'center', gap: 14, width: '100%',
padding: '12px 14px', cursor: 'pointer', borderRadius: 14,
background: i === activePod ? '#fff' : 'transparent',
border: i === activePod ? '1px solid var(--rule-thin)' : '1px solid transparent',
boxShadow: i === activePod ? '0 10px 28px -18px rgba(7,29,73,0.3)' : 'none',
textAlign: lang === 'fa' ? 'right' : 'left', transition: 'background 150ms, box-shadow 150ms',
}}
onMouseEnter={e => { if (i !== activePod) (e.currentTarget as HTMLElement).style.background = 'rgba(7,29,73,0.04)' }}
onMouseLeave={e => { if (i !== activePod) (e.currentTarget as HTMLElement).style.background = 'transparent' }}
>
<div style={{ position: 'relative', flexShrink: 0 }}>
<img src={p.cover} alt="" style={{ width: 56, height: 56, borderRadius: 12, objectFit: 'cover', display: 'block' }} />
{i === activePod && (
<div style={{ position: 'absolute', inset: 0, borderRadius: 12, background: 'rgba(7,29,73,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 0, height: 0, borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderLeft: '10px solid #fff', marginLeft: 2 }} />
</div>
)}
display: 'flex', alignItems: 'center', gap: 14, width: '100%', cursor: 'pointer',
background: i === activePod ? '#fff' : 'transparent', border: 'none', borderRadius: 12, padding: 10,
boxShadow: i === activePod ? '0 12px 30px -18px rgba(3,35,64,0.3)' : 'none',
flexDirection: lang === 'fa' ? 'row-reverse' : 'row', textAlign: lang === 'fa' ? 'right' : 'left',
}}>
<img src={p.cover} alt="" style={{ width: 92, height: 64, borderRadius: 10, objectFit: 'cover', flexShrink: 0, display: 'block' }} />
<div style={{ flex: 1, minWidth: 0, direction: 'rtl' }}>
<div style={{ fontSize: 13.5, fontWeight: 900, color: NAVY, marginBottom: 5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p[lang].title}</div>
<div style={{ fontSize: 11, color: 'rgba(3,35,64,0.5)', lineHeight: 1.7, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{p[lang].body}</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 800, color: 'var(--ink)', marginBottom: 3, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p[lang].title}</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)' }}>{p[lang].ep} · {p.duration}</div>
</div>
{i === activePod
? <div style={{ width: 7, height: 7, borderRadius: '50%', background: '#00AEEF', flexShrink: 0 }} />
: <span style={{ color: 'var(--ink-5)', fontSize: 16, flexShrink: 0 }}>{lang === 'fa' ? '▸' : '▸'}</span>}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 10, color: GOLD, flexShrink: 0, alignSelf: 'flex-start' }}>
{p.duration}
</span>
</button>
))}
</div>
{/* description — second DOM → left in RTL */}
<div style={{ order: lang === 'fa' ? 1 : 2, direction: 'rtl' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, justifyContent: 'flex-start' }}>
<span style={{ fontSize: 16 }}>🎙</span>
<h3 style={{ fontSize: 'clamp(16px,1.6vw,22px)', fontWeight: 900, color: NAVY, margin: 0, lineHeight: 1.4 }}>{pod[lang].title}</h3>
</div>
<p style={{ fontSize: 13, lineHeight: 2.1, color: 'rgba(3,35,64,0.72)', margin: '0 0 16px', textAlign: 'justify' }}>{pod[lang].body}</p>
<div style={{ fontSize: 12.5, fontWeight: 800, color: GOLD, marginBottom: 10 }}>مهمان برنامه: {pod[lang].guest}</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
{POD_HASHTAGS.map((h, i) => <span key={i} style={{ fontSize: 12, fontWeight: 700, color: GOLD }}>{h}</span>)}
</div>
</div>
</div>
</div>
</div>
)}