THU figma redesign
This commit is contained in:
parent
ee4892df75
commit
238464e073
|
|
@ -73,7 +73,24 @@
|
||||||
"Bash(python assets/scripts/search.py \"steel industry admin dashboard CMS analytics RTL\" --design-system -p \"Foolad Panel\")",
|
"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)",
|
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\DATIS STAR\\\\پنل\\\\src\\\\app\\\\api\\\\\" -Recurse -File)",
|
||||||
"Read(//c/Users/DATIS STAR/**)",
|
"Read(//c/Users/DATIS STAR/**)",
|
||||||
|
<<<<<<< HEAD
|
||||||
"Bash(git branch *)"
|
"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": [
|
"additionalDirectories": [
|
||||||
"c:\\Users\\DATIS STAR\\پنل"
|
"c:\\Users\\DATIS STAR\\پنل"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<<<<<<< HEAD
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|
@ -27,3 +28,34 @@ dist-ssr
|
||||||
/[0-9]*.png
|
/[0-9]*.png
|
||||||
brand_pages/
|
brand_pages/
|
||||||
/موکاپ*.jpg
|
/موکاپ*.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
|
||||||
|
|
|
||||||
|
|
@ -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 30–60% 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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
# SECURITY.md — Security Rules for This Project
|
||||||
|
|
||||||
|
> **To the AI agent reading this:** These are **mandatory, non-negotiable rules**. Follow them on every change you make, even when the user doesn't mention security. When a requested feature conflicts with a rule here, implement the secure version and tell the user what you did and why. If you are unsure whether something is safe, **stop and ask** rather than guessing. Never disable a security control to "make it work" — fix the underlying cause instead.
|
||||||
|
|
||||||
|
This file exists because apps built fast (including AI-assisted ones) repeatedly ship the same handful of vulnerabilities. The goal is an app that is "immune" to the common classes of attack by **default**, not as an afterthought.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The Golden Rules (read these first)
|
||||||
|
|
||||||
|
1. **The frontend is public.** Anything in client-side code, network responses, or the browser is visible to and editable by an attacker. Never trust it. All real security must live on the server / database.
|
||||||
|
2. **Never trust input.** Validate, sanitize, and authorize *every* input on the server — query params, headers, body, file uploads, cookies, and webhook payloads.
|
||||||
|
3. **Authenticate, then authorize, on every request.** Logging in is not enough. Check *this specific user is allowed to do this specific action on this specific resource* — server-side, every time.
|
||||||
|
4. **Secrets stay on the server.** No keys, tokens, passwords, or connection strings in frontend code, repos, or logs. Ever.
|
||||||
|
5. **Fail closed.** When something is ambiguous or errors out, deny access. Default to the most restrictive option.
|
||||||
|
6. **Least privilege everywhere.** Every key, role, token, and DB account gets the minimum permissions it needs and nothing more.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Secrets & Credentials
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Hardcode API keys, passwords, JWT secrets, DB URLs, or private tokens anywhere in source.
|
||||||
|
- Put secrets in frontend/client code or in any `NEXT_PUBLIC_`, `VITE_`, `REACT_APP_`, or otherwise client-exposed env var.
|
||||||
|
- Commit `.env`, `.env.local`, credential files, `*.pem`, or service-account JSON to git.
|
||||||
|
- Log secrets, tokens, or full request bodies that may contain them.
|
||||||
|
- Reuse the same secret across dev/staging/prod.
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Load secrets from server-side environment variables only.
|
||||||
|
- Add `.env*`, `*.pem`, `*.key`, `secrets/`, and credential files to `.gitignore` *before* the first commit.
|
||||||
|
- Provide a `.env.example` with key **names only** and dummy values.
|
||||||
|
- Use a distinct, high-entropy (32+ random bytes) value for each signing/encryption secret.
|
||||||
|
- Assume any secret that ever touched the frontend or a public repo is **compromised** — rotate it.
|
||||||
|
- Use a secrets manager (or the platform's encrypted env store) in production, not files on disk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Authentication
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Roll your own crypto, password hashing, or session logic when a vetted library/provider exists.
|
||||||
|
- Store passwords in plaintext or with fast/weak hashes (MD5, SHA1, unsalted SHA256).
|
||||||
|
- Trust a `userId`, `role`, `isAdmin`, or `email` value that came from the client to decide who someone is.
|
||||||
|
- Put auth state only in `localStorage` and treat its presence as proof of identity.
|
||||||
|
- Leave default/seed credentials (`admin/admin`) in any environment.
|
||||||
|
- Return "user not found" vs "wrong password" differently (enables user enumeration).
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Use a maintained auth library/provider (e.g. the platform's auth, Auth.js, Lucia, Clerk, Supabase Auth) and follow its server-side session verification.
|
||||||
|
- Hash passwords with bcrypt, scrypt, or Argon2 with a proper cost factor.
|
||||||
|
- Verify the session/token **on the server** for every protected request and derive identity from it — never from request body params.
|
||||||
|
- Enforce a password policy + check against known-breached passwords where possible.
|
||||||
|
- Implement account lockout / throttling on repeated failed logins.
|
||||||
|
- Offer/encourage MFA for sensitive accounts.
|
||||||
|
- Expire sessions, rotate on privilege change, and invalidate on logout server-side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Authorization (the #1 vibe-coded flaw)
|
||||||
|
|
||||||
|
> Most rapidly-built apps authenticate but barely authorize. This is where IDOR, broken access control, and data leaks come from.
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Hide a button/route on the frontend and call it "protected." UI hiding is not access control.
|
||||||
|
- Use sequential/guessable IDs as the *only* thing protecting a resource (`/api/orders/1043` → an attacker just tries `1044`). This is **IDOR**.
|
||||||
|
- Assume that because the UI only sends valid requests, the server only receives valid requests.
|
||||||
|
- Let one user's token read/modify another user's data because the query didn't filter by owner.
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- On every data access, check ownership/permission server-side: `WHERE owner_id = :current_user` (or equivalent), not just `WHERE id = :id`.
|
||||||
|
- Re-check role/permission for every privileged action on the server, on every call.
|
||||||
|
- Enforce authorization at the **lowest layer possible** (DB row-level security + API layer), so a missed check in one place doesn't expose everything.
|
||||||
|
- Use unguessable IDs (UUIDs/ULIDs) for resources, *and still* authorize them.
|
||||||
|
- Centralize authorization logic so it's consistent and auditable, not copy-pasted per route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Database Security (Supabase / Firebase / direct DB)
|
||||||
|
|
||||||
|
> Vibe-coded apps frequently expose the DB straight to the browser with permissive rules. This is catastrophic.
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Ship Supabase/Postgres tables with **Row Level Security (RLS) disabled** on anything holding user data.
|
||||||
|
- Use Firebase/Firestore rules like `allow read, write: if true;` or test-mode rules in production.
|
||||||
|
- Expose a service-role / admin DB key to the client (the anon/public key is the only client-side key, and even that needs RLS behind it).
|
||||||
|
- Build SQL by string concatenation or template interpolation with user input.
|
||||||
|
- Run app queries as a superuser/admin DB account.
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Enable RLS on every table and write explicit policies scoped to the authenticated user.
|
||||||
|
- Write Firestore/RTDB rules that check `request.auth` and validate ownership and shape.
|
||||||
|
- Use parameterized queries / prepared statements / a query builder / ORM bindings — **always** (prevents SQL injection).
|
||||||
|
- Give the app a least-privilege DB role (no DROP/ALTER, only needed tables).
|
||||||
|
- Validate data shape and types at the DB boundary, not just the UI.
|
||||||
|
- Keep the service-role key strictly server-side for trusted admin operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Input Validation & Injection
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Pass user input directly into SQL, shell commands, `eval`, template engines, `dangerouslySetInnerHTML`, file paths, or `Function()`.
|
||||||
|
- Rely on frontend validation as a security boundary (it's UX only).
|
||||||
|
- Build file paths from user input without normalizing (enables **path traversal**, `../../etc/passwd`).
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Validate every input server-side against a strict schema (e.g. Zod, Pydantic, Joi) — type, length, format, allowed values.
|
||||||
|
- Allow-list rather than block-list whenever possible.
|
||||||
|
- Use parameterized queries for SQL and safe APIs for OS/command operations (avoid shelling out with user input at all).
|
||||||
|
- Reject unexpected fields to prevent **mass assignment** (don't blindly spread `req.body` into a DB record — pick allowed fields explicitly).
|
||||||
|
- Canonicalize and confine file paths to an intended directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Output / XSS / Content
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Render untrusted content as raw HTML (`dangerouslySetInnerHTML`, `innerHTML`, `v-html`, `{{{ }}}`) without sanitizing.
|
||||||
|
- Reflect user input into responses unescaped.
|
||||||
|
- Inject user-controlled values into `<script>`, inline event handlers, or URLs (`javascript:`).
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Rely on framework auto-escaping (React/Vue/Svelte escape by default — keep it that way).
|
||||||
|
- Sanitize any HTML you *must* render with a maintained sanitizer (e.g. DOMPurify) and a strict allow-list.
|
||||||
|
- Set a strong **Content-Security-Policy** to limit script sources.
|
||||||
|
- Encode output for the correct context (HTML, attribute, JS, URL).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. APIs, CORS & Rate Limiting
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Set `Access-Control-Allow-Origin: *` together with credentials, or wildcard CORS on authenticated APIs.
|
||||||
|
- Ship public endpoints with no rate limiting (enables brute force, scraping, credential stuffing, cost-blowup, and DoS).
|
||||||
|
- Leave debug/admin/internal endpoints reachable in production.
|
||||||
|
- Return different responses that leak whether a record exists when it shouldn't.
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Restrict CORS to an explicit allow-list of known origins.
|
||||||
|
- Rate-limit by IP and by user/account on auth, write, and expensive endpoints; add exponential backoff/lockout for auth.
|
||||||
|
- Require auth on every non-public endpoint and verify it server-side.
|
||||||
|
- Validate `Content-Type` and reject unexpected methods.
|
||||||
|
- Protect state-changing requests against **CSRF** (SameSite cookies + CSRF tokens, or token-based auth not stored in cookies).
|
||||||
|
- Treat webhooks as untrusted: verify signatures before acting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sessions, Tokens & JWTs
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Accept JWTs with `alg: none` or let the client choose the algorithm.
|
||||||
|
- Sign JWTs with a weak/guessable/shared secret.
|
||||||
|
- Issue tokens with no expiry, or store sensitive long-lived tokens in `localStorage` (XSS-readable).
|
||||||
|
- Trust JWT claims without verifying the signature server-side.
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Pin the expected algorithm and verify the signature on every request.
|
||||||
|
- Use short-lived access tokens + rotating refresh tokens.
|
||||||
|
- Prefer `httpOnly`, `Secure`, `SameSite` cookies for session tokens.
|
||||||
|
- Include and validate `exp`, `iss`, `aud`; revoke on logout/compromise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. File Uploads
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Trust the client-provided filename, extension, or MIME type.
|
||||||
|
- Store uploads in a web-served directory where they can be executed.
|
||||||
|
- Allow arbitrary file types or unbounded file sizes.
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Validate type by content (magic bytes), enforce a size limit, and allow-list extensions.
|
||||||
|
- Generate a new random server-side filename; never use the user's path.
|
||||||
|
- Store outside the web root or in object storage with no-execute and least-privilege access.
|
||||||
|
- Scan uploads where feasible and serve them from a separate domain/sandbox with `Content-Disposition`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. HTTP Security Headers & Transport
|
||||||
|
|
||||||
|
**ALWAYS** set (or have the framework/host set) at minimum:
|
||||||
|
- `Strict-Transport-Security` (HSTS) — and enforce HTTPS everywhere; redirect HTTP→HTTPS.
|
||||||
|
- `Content-Security-Policy` — restrict scripts/styles/connect sources.
|
||||||
|
- `X-Content-Type-Options: nosniff`
|
||||||
|
- `X-Frame-Options: DENY` (or CSP `frame-ancestors`) to prevent clickjacking.
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||||
|
- Sensible `Permissions-Policy`.
|
||||||
|
|
||||||
|
**NEVER** serve auth or sensitive data over plain HTTP, or mix HTTP content into an HTTPS app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Error Handling & Logging
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Return stack traces, SQL errors, file paths, or internal details to the client.
|
||||||
|
- Log passwords, tokens, full card numbers, or other secrets/PII.
|
||||||
|
- Run production with debug mode on.
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Return generic error messages to users; log detail server-side with correlation IDs.
|
||||||
|
- Log security-relevant events (auth failures, access denials, privilege changes) for audit.
|
||||||
|
- Scrub/redact sensitive fields before logging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Dependencies & Supply Chain
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Add a dependency you don't need, or pull random unvetted packages for a one-liner.
|
||||||
|
- Ignore known-vulnerable dependency warnings.
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Run `npm audit` / `pip-audit` / `osv-scanner` (or equivalent) and fix high/critical issues.
|
||||||
|
- Pin versions and use a lockfile; keep dependencies updated.
|
||||||
|
- Verify package names carefully (watch for typosquats).
|
||||||
|
- Enable automated dependency security updates (e.g. Dependabot) where possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Sensitive Data & Privacy
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Collect or store more personal data than the feature needs.
|
||||||
|
- Store sensitive data (PII, payment, health) unencrypted at rest.
|
||||||
|
- Send sensitive data to third-party services/analytics without need and consent.
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Encrypt sensitive data at rest and in transit.
|
||||||
|
- Apply least-privilege access to PII; mask/redact in UIs and logs.
|
||||||
|
- Offboard payment handling to a PCI-compliant provider (e.g. Stripe) rather than touching card data yourself.
|
||||||
|
- Provide data deletion/export paths where regulations require.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. AI / LLM-Specific (if the app calls an LLM)
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Concatenate untrusted user input directly into a system prompt and treat the model output as trusted (**prompt injection**).
|
||||||
|
- Give the model unrestricted tools/DB/shell access driven by user-controlled text.
|
||||||
|
- Expose your LLM provider API key to the client.
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Treat model output as untrusted input — validate and authorize any action it triggers.
|
||||||
|
- Keep the LLM key server-side and put your own rate limits + spend caps around it.
|
||||||
|
- Separate trusted instructions from untrusted user content; constrain tool use with allow-lists and per-action authorization.
|
||||||
|
- Filter/limit what data the model can retrieve based on the requesting user's permissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Server-Side Request & Redirect Safety
|
||||||
|
|
||||||
|
**NEVER**
|
||||||
|
- Fetch a user-supplied URL from the server without restriction (**SSRF** — can hit internal metadata endpoints / private network).
|
||||||
|
- Redirect to a user-supplied URL without validation (**open redirect** — aids phishing).
|
||||||
|
|
||||||
|
**ALWAYS**
|
||||||
|
- Allow-list domains/schemes for any server-side fetch; block private/internal IP ranges and metadata IPs.
|
||||||
|
- Validate redirect targets against an allow-list or use relative paths only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Ship Security Checklist
|
||||||
|
|
||||||
|
Run through this before any deploy. Treat unchecked items as blockers.
|
||||||
|
|
||||||
|
- [ ] No secrets in code, repo history, logs, or client bundles; `.env*` gitignored; keys rotated if ever exposed.
|
||||||
|
- [ ] Auth verified server-side on every protected route; passwords hashed with bcrypt/argon2; no default creds.
|
||||||
|
- [ ] Every data read/write is authorized by ownership/role server-side (no IDOR); privileged actions re-checked.
|
||||||
|
- [ ] RLS / DB rules enabled and scoped per-user; service-role key server-side only; no `if true` rules.
|
||||||
|
- [ ] All queries parameterized; all input validated server-side against a schema; mass assignment prevented.
|
||||||
|
- [ ] Output escaped/sanitized; CSP set; no unsanitized raw-HTML rendering.
|
||||||
|
- [ ] CORS locked to known origins; rate limiting on auth/write/expensive endpoints; CSRF handled; webhooks signature-verified.
|
||||||
|
- [ ] JWTs verify signature + pinned alg + expiry; session cookies `httpOnly`/`Secure`/`SameSite`.
|
||||||
|
- [ ] File uploads validated by content, size-limited, randomly named, stored no-execute.
|
||||||
|
- [ ] Security headers + HTTPS enforced (HSTS, nosniff, frame-ancestors, referrer-policy).
|
||||||
|
- [ ] Generic error messages to users; debug off in prod; no secrets/PII in logs.
|
||||||
|
- [ ] Dependency audit clean (no high/critical); lockfile committed.
|
||||||
|
- [ ] Sensitive data minimized + encrypted; payments via compliant provider.
|
||||||
|
- [ ] LLM key server-side; model output treated as untrusted; SSRF/open-redirect protections in place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to use this file with the AI agent
|
||||||
|
|
||||||
|
- Keep this file at the project root as `SECURITY.md` and reference it in your `CLAUDE.md` / agent instructions: *"Follow all rules in SECURITY.md on every change."*
|
||||||
|
- When asking for a feature, you can add: *"Implement this per SECURITY.md and flag any rule it touches."*
|
||||||
|
- Periodically ask: *"Audit the current codebase against SECURITY.md and list violations by severity."*
|
||||||
|
- If the agent ever proposes weakening a control for convenience, that's a red flag — ask for a secure alternative instead.
|
||||||
|
|
@ -0,0 +1,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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -71,6 +71,7 @@ function topbarHtml(active, primaryLabel) {
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<button data-tab="articles" class="${active==='articles'?'tab-active':''}">مطالب</button>
|
<button data-tab="articles" class="${active==='articles'?'tab-active':''}">مطالب</button>
|
||||||
<button data-tab="risks" class="${active==='risks'?'tab-active':''}">ریسکها</button>
|
<button data-tab="risks" class="${active==='risks'?'tab-active':''}">ریسکها</button>
|
||||||
|
<button data-tab="users" class="${active==='users'?'tab-active':''}">کاربران</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="newBtn" class="primary">${primaryLabel}</button>
|
<button id="newBtn" class="primary">${primaryLabel}</button>
|
||||||
|
|
@ -84,6 +85,7 @@ function wireTabs() {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
if (btn.dataset.tab === 'articles') renderList();
|
if (btn.dataset.tab === 'articles') renderList();
|
||||||
else if (btn.dataset.tab === 'risks') renderRisksList();
|
else if (btn.dataset.tab === 'risks') renderRisksList();
|
||||||
|
else if (btn.dataset.tab === 'users') renderUsersList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
$('#logoutBtn').addEventListener('click', async () => {
|
$('#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());
|
api('/api/auth/me').then(() => renderList()).catch(() => renderLogin());
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,57 @@ app.delete('/api/risks/:id', authRequired, (req, res) => {
|
||||||
res.status(204).end();
|
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) ----------
|
// ---------- prices (scraped from tgju.org every 2 min) ----------
|
||||||
app.get('/api/prices', (_req, res) => {
|
app.get('/api/prices', (_req, res) => {
|
||||||
const rows = db
|
const rows = db
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -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()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 508 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 471 KiB |
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ import SpreadFeatures from './sections/SpreadFeatures'
|
||||||
import IntegrationsSection from './sections/IntegrationsSection'
|
import IntegrationsSection from './sections/IntegrationsSection'
|
||||||
// import InternalNewsSection from './sections/InternalNewsSection' // «تازهترین اخبار صنعت» — disabled, restore anytime
|
// import InternalNewsSection from './sections/InternalNewsSection' // «تازهترین اخبار صنعت» — disabled, restore anytime
|
||||||
import RadarTechSection from './sections/RadarTechSection'
|
import RadarTechSection from './sections/RadarTechSection'
|
||||||
|
import AtAGlanceSection from './sections/AtAGlanceSection'
|
||||||
|
import FeaturedReportBanner from './sections/FeaturedReportBanner'
|
||||||
import MapEventsSection from './sections/MapEventsSection'
|
import MapEventsSection from './sections/MapEventsSection'
|
||||||
import FactoryReportsScroll from './sections/FactoryReportsScroll'
|
import FactoryReportsScroll from './sections/FactoryReportsScroll'
|
||||||
import NewsletterMarquee from './sections/NewsletterMarquee'
|
import NewsletterMarquee from './sections/NewsletterMarquee'
|
||||||
|
|
@ -16,7 +18,10 @@ export default function Home() {
|
||||||
<div dir={lang === 'fa' ? 'rtl' : 'ltr'}>
|
<div dir={lang === 'fa' ? 'rtl' : 'ltr'}>
|
||||||
<SpreadFeatures />
|
<SpreadFeatures />
|
||||||
{/* <InternalNewsSection /> «تازهترین اخبار صنعت» — disabled, restore anytime */}
|
{/* <InternalNewsSection /> «تازهترین اخبار صنعت» — disabled, restore anytime */}
|
||||||
<RadarTechSection />
|
<RadarTechSection only="radar" />
|
||||||
|
<AtAGlanceSection />
|
||||||
|
<RadarTechSection only="approach" />
|
||||||
|
<FeaturedReportBanner />
|
||||||
<IntegrationsSection />
|
<IntegrationsSection />
|
||||||
<MapEventsSection />
|
<MapEventsSection />
|
||||||
<NewsletterMarquee />
|
<NewsletterMarquee />
|
||||||
|
|
|
||||||
|
|
@ -60,27 +60,27 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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) */}
|
{/* heading with gold rule (hidden when embedded in a page that has its own header) */}
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, justifyContent: 'flex-start', marginBottom: 40 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, justifyContent: 'flex-start', marginBottom: 24 }}>
|
||||||
<h2 style={{ fontSize: 'clamp(26px, 2.6vw, 38px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0, order: isFa ? 1 : 2 }}>
|
<h2 style={{ fontSize: 'clamp(22px, 2.2vw, 30px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0, order: isFa ? 1 : 2 }}>
|
||||||
{T.heading}
|
{T.heading}
|
||||||
</h2>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* image on the LEFT, text on the RIGHT in RTL (matches the reference).
|
{/* image on the LEFT, text on the RIGHT (matches the reference).
|
||||||
In RTL, the first flex child lands on the right — so we use row-reverse to push image left. */}
|
Container is LTR; image is the first DOM child, so plain `row` lands it on the left. */}
|
||||||
<div
|
<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"
|
className="max-lg:!flex-col max-lg:!gap-8"
|
||||||
>
|
>
|
||||||
{/* ── IMAGE side (with stacked-card shadow) ── */}
|
{/* ── 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 */}
|
{/* back card (offset, behind) — offset away from the text card */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', inset: 0,
|
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 */}
|
{/* front image — portrait on desktop, shorter landscape on mobile */}
|
||||||
<div
|
<div
|
||||||
style={{ position: 'relative', borderRadius: 18, overflow: 'hidden', boxShadow: '0 40px 90px -30px rgba(7,29,73,0.45)' }}
|
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}>
|
<AnimatePresence mode="wait" custom={dir}>
|
||||||
<motion.img
|
<motion.img
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,52 +1,18 @@
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import Globe from '@/components/ui/globe'
|
import Globe from '@/components/ui/globe'
|
||||||
import EventCalendar from '@/components/ui/EventCalendar'
|
import StrategicEvents from '@/components/ui/StrategicEvents'
|
||||||
import { useLang } from '@/context/LangContext'
|
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() {
|
export default function MapEventsSection() {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const t = T[lang]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', borderBottom: '1px solid var(--rule-thin)', overflow: 'hidden' }}>
|
<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 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">
|
<div style={{ display: 'flex', alignItems: 'stretch', minHeight: 520 }} className="max-md:flex-col">
|
||||||
|
|
||||||
{/* ── Text + Calendar — RIGHT in RTL ── */}
|
{/* ── Strategic events — RIGHT in RTL ── */}
|
||||||
<div style={{ flex: '4 4 0', minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: '4 4 0', minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '48px 24px' }} className="max-md:!px-5">
|
||||||
{/* scanner heading (moved here from above the globe) */}
|
<StrategicEvents />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Globe — LEFT, no text ── */}
|
{/* ── Globe — LEFT, no text ── */}
|
||||||
|
|
|
||||||
|
|
@ -40,15 +40,18 @@ const T = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RadarTechSection() {
|
export default function RadarTechSection({ only }: { only?: 'radar' | 'approach' }) {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const t = T[lang]
|
const t = T[lang]
|
||||||
|
const showRadar = only !== 'approach'
|
||||||
|
const showApproach = only !== 'radar'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper-2)' }}>
|
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper-2)' }}>
|
||||||
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
|
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
|
||||||
|
|
||||||
{/* ══ رادار آینده ══ */}
|
{/* ══ رادار آینده ══ */}
|
||||||
|
{showRadar && (
|
||||||
<div style={{ paddingTop: 64, paddingBottom: 56 }}>
|
<div style={{ paddingTop: 64, paddingBottom: 56 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 32, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 32, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -65,11 +68,13 @@ export default function RadarTechSection() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* divider */}
|
{/* divider — only when both halves render together */}
|
||||||
<div style={{ height: 1, background: 'var(--rule-thin)' }} />
|
{showRadar && showApproach && <div style={{ height: 1, background: 'var(--rule-thin)' }} />}
|
||||||
|
|
||||||
{/* ══ حوزههای پژوهشی ══ */}
|
{/* ══ حوزههای پژوهشی ══ */}
|
||||||
|
{showApproach && (
|
||||||
<div style={{ paddingTop: 56, paddingBottom: 64 }}>
|
<div style={{ paddingTop: 56, paddingBottom: 64 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 32, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 32, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -108,6 +113,7 @@ export default function RadarTechSection() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,72 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
|
||||||
|
const NAVY = '#032340'
|
||||||
|
const GOLD = '#CD9E53'
|
||||||
|
|
||||||
|
const HASHTAGS = ['#فولاد _ آینده', '#فولاد _ آینده', '#فولاد _ آینده']
|
||||||
|
|
||||||
const VIDEOS = [
|
const VIDEOS = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
fa: { title: 'چشمانداز فولاد ایران در ۱۴۰۴', desc: 'بررسی روندهای تولید، صادرات و سیاستگذاری صنعت فولاد ایران در سال پیش رو' },
|
fa: { title: 'نگاهی به آن سوی مرزهای تولید', guest: 'گفتگو با مهندس سیامک فجری',
|
||||||
en: { title: 'Iran Steel Outlook 1404', desc: 'Production trends, exports and policy analysis for Iran\'s steel industry' },
|
body: 'در ویدیوکست «Steel Horizon»، ما فراتر از دادههای خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت میرویم. اینجا محل تلاقی استراتژیهای کلان، تکنولوژیهای برهمزننده و رهبرانی است که فولاد فردا را نه با کورهها، که با «بینش» میسازند. ما طوفانهای بازار را تحلیل میکنیم تا شما با اطمینان، مسیر آینده را ترسیم کنید.\nصنعت فولاد در حال ورود به پیچیدهترین چرخه حیات خود است؛ جایی که هوش مصنوعی، مقررات کربنی و ژئواکونومی، قواعد بازی را بازنویسی کردهاند. در «Steel Logic» با تحلیلگران ارشد و استراتژیستهای تراز اول، به گفتگو مینشینیم تا لایههای پنهان این پیچیدگیها را آشکار کنیم. اینجا خبری از هیجانزدگی نیست؛ فقط تحلیلهای دادهمحور و تصمیمساز.' },
|
||||||
ep: 'EP.12', duration: '۴۲ دقیقه', thumb: '/news/photo-1504307651254-35680f356dfd.jpg',
|
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,
|
id: 2,
|
||||||
fa: { title: 'تحلیل بازار جهانی فلزات پایه', desc: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره فولاد' },
|
fa: { title: 'تحلیل بازار جهانی فلزات پایه', guest: 'گفتگو با کارشناسان بازار',
|
||||||
en: { title: 'Global Base Metals Analysis', desc: 'Price volatility in copper, aluminum and zinc and its steel chain impact' },
|
body: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره فولاد را با تحلیلگران بازار بررسی میکنیم.' },
|
||||||
ep: 'EP.11', duration: '۳۸ دقیقه', thumb: '/news/photo-1558618666-fcd25c85cd64.jpg',
|
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,
|
id: 3,
|
||||||
fa: { title: 'ژئوپلیتیک و صادرات فولاد', desc: 'تأثیر تحولات منطقهای بر بازارهای صادراتی فولاد ایران و فرصتهای پیش رو' },
|
fa: { title: 'ژئوپلیتیک و صادرات فولاد', guest: 'گفتگو با تحلیلگران ژئواکونومی',
|
||||||
en: { title: 'Geopolitics & Steel Exports', desc: 'How regional developments shape Iran\'s steel export markets' },
|
body: 'تأثیر تحولات منطقهای بر بازارهای صادراتی فولاد ایران و فرصتهای پیش رو.' },
|
||||||
ep: 'EP.10', duration: '۵۱ دقیقه', thumb: '/news/photo-1504328345606-18bbc8c9d7d1.jpg',
|
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,
|
id: 4,
|
||||||
fa: { title: 'سرمایهگذاری در صنعت فولاد', desc: 'فرصتها و چالشهای سرمایهگذاری در بخشهای مختلف زنجیره ارزش فولاد' },
|
fa: { title: 'سرمایهگذاری در صنعت فولاد', guest: 'گفتگو با مدیران سرمایهگذاری',
|
||||||
en: { title: 'Steel Industry Investment', desc: 'Opportunities and challenges across the steel value chain' },
|
body: 'فرصتها و چالشهای سرمایهگذاری در بخشهای مختلف زنجیره ارزش فولاد.' },
|
||||||
ep: 'EP.09', duration: '۴۵ دقیقه', thumb: '/news/photo-1611273426858-450d8e3c9fce.jpg',
|
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 = [
|
const PODCASTS = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
fa: { title: 'نبض صنعت — فصل سوم', ep: 'قسمت ۱۲', desc: 'گفتگو با مدیران ارشد صنعت فولاد درباره چالشهای تامین مالی و توسعه ظرفیت' },
|
epNum: 'اپیزود ۷۷', date: '۲۵ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱۰:۵۶', cover: '/news/photo-1540575467063-178a50c2df87.jpg',
|
||||||
en: { title: 'Industry Pulse — Season 3', ep: 'Episode 12', desc: 'Steel executives on financing challenges and capacity expansion' },
|
fa: { title: 'کالبدشکافی عقلانی پیچیدگیهای صنعتی', ep: 'قسمت ۱۲', guest: 'مهندس سیامک شجاعی',
|
||||||
duration: '۵۸ دقیقه', cover: '/news/photo-1540575467063-178a50c2df87.jpg',
|
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,
|
id: 2,
|
||||||
fa: { title: 'صدای بازار', ep: 'قسمت ۸', desc: 'تحلیل هفتگی قیمتهای شمش، میلگرد و مقاطع با کارشناسان بازار' },
|
epNum: 'اپیزود ۷۶', date: '۱۸ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱:۴۰:۱۸', cover: '/news/photo-1566873535350-6586b0b7a1f2.jpg',
|
||||||
en: { title: 'Market Voice', ep: 'Episode 8', desc: 'Weekly analysis of billet, rebar and section prices with market experts' },
|
fa: { title: 'آزمایشگاه کوره', ep: 'قسمت ۸', guest: 'کارشناسان بازار',
|
||||||
duration: '۳۲ دقیقه', cover: '/news/photo-1566873535350-6586b0b7a1f2.jpg',
|
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,
|
id: 3,
|
||||||
fa: { title: 'آینده سبز فولاد', ep: 'قسمت ۵', desc: 'مسیر صنعت فولاد ایران به سمت کاهش کربن و پایداری' },
|
epNum: 'اپیزود ۷۵', date: '۱۱ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱:۴۰:۱۸', cover: '/news/photo-1578662996442-48f60103fc96.jpg',
|
||||||
en: { title: 'Green Steel Future', ep: 'Episode 5', desc: 'Iran\'s steel sector path toward carbon reduction and sustainability' },
|
fa: { title: 'معمار فولاد', ep: 'قسمت ۵', guest: 'رهبران صنعت',
|
||||||
duration: '۴۴ دقیقه', cover: '/news/photo-1578662996442-48f60103fc96.jpg',
|
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 className="max-w-7xl mx-auto px-12 max-md:px-5">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: gridCols }} className="max-lg:!grid-cols-1">
|
<div style={{ display: 'grid', gridTemplateColumns: gridCols }} className="max-lg:!grid-cols-1">
|
||||||
|
|
||||||
{/* ── VIDEOCAST 70% ── */}
|
{/* ── VIDEOCAST (full-width featured player + playlist + description) ── */}
|
||||||
{showVideo && (
|
{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 */}
|
{/* heading */}
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 28, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24, justifyContent: 'flex-start' }}>
|
||||||
<div>
|
<div style={{ width: 4, height: 28, background: GOLD, borderRadius: 2, order: lang === 'fa' ? 2 : 1 }} />
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
<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 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>
|
</div>
|
||||||
|
|
||||||
{/* featured */}
|
{/* featured player */}
|
||||||
<div style={{ marginBottom: 16, position: 'relative', overflow: 'hidden', background: '#000', flex: 1, minHeight: 440, borderRadius: 14 }} className="max-md:!min-h-[260px]">
|
<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%', minHeight: 440, objectFit: 'cover', display: 'block', opacity: 0.82, position: 'absolute', inset: 0 }} />
|
<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(7,29,73,0.88) 0%, transparent 55%)' }} />
|
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,35,64,0.55) 0%, transparent 45%)' }} />
|
||||||
{/* play */}
|
{/* center play */}
|
||||||
<div style={{
|
<button aria-label="play" style={{
|
||||||
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
|
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
|
||||||
width: 54, height: 54, borderRadius: '50%',
|
width: 64, height: 64, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.6)',
|
||||||
background: 'rgba(255,255,255,0.12)', backdropFilter: 'blur(4px)',
|
background: 'rgba(255,255,255,0.14)', backdropFilter: 'blur(4px)', cursor: 'pointer',
|
||||||
border: '2px solid rgba(255,255,255,0.45)',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ width: 0, height: 0, borderTop: '10px solid transparent', borderBottom: '10px solid transparent', borderLeft: '18px solid #fff', marginLeft: 4 }} />
|
<div style={{ width: 0, height: 0, borderTop: '11px solid transparent', borderBottom: '11px solid transparent', borderLeft: '19px solid #fff', marginLeft: 4 }} />
|
||||||
</div>
|
</button>
|
||||||
{/* meta */}
|
{/* controls bar */}
|
||||||
<div style={{ position: 'absolute', bottom: 0, right: 0, left: 0, padding: '16px 20px' }}>
|
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 16px 14px', direction: 'ltr' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
<div style={{ height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.3)', marginBottom: 12 }}>
|
||||||
<span style={{ fontSize: 9, fontWeight: 700, background: 'var(--red)', color: '#fff', padding: '2px 8px', letterSpacing: '1px' }}>{VIDEOS[activeVideo].ep}</span>
|
<div style={{ width: '22%', height: '100%', borderRadius: 2, background: GOLD, position: 'relative' }}>
|
||||||
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.55)' }}>{VIDEOS[activeVideo].duration}</span>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* episode cards */}
|
{/* playlist (right) + description (left) */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12 }} className="max-sm:!grid-cols-2">
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.2fr', gap: 40 }} className="max-lg:!grid-cols-1 max-lg:!gap-8">
|
||||||
{VIDEOS.map((v, i) => (
|
|
||||||
<button key={v.id} onClick={() => setActiveVideo(i)} style={{
|
{/* playlist — first DOM child → right in RTL */}
|
||||||
cursor: 'pointer', padding: 0, background: 'none', textAlign: lang === 'fa' ? 'right' : 'left',
|
<div style={{ order: lang === 'fa' ? 2 : 1 }}>
|
||||||
border: i === activeVideo ? '2px solid var(--ink)' : '2px solid var(--rule-thin)',
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, justifyContent: 'flex-end' }}>
|
||||||
transition: 'border-color 150ms', display: 'flex', flexDirection: 'column',
|
<span style={{ fontSize: 14, fontWeight: 900, color: NAVY }}>پلی لیست</span>
|
||||||
}}>
|
<span style={{ fontSize: 16 }}>🎬</span>
|
||||||
<div style={{ position: 'relative', overflow: 'hidden' }}>
|
</div>
|
||||||
<img src={v.thumb} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block', opacity: i === activeVideo ? 1 : 0.6, transition: 'opacity 150ms' }} />
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<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>
|
{VIDEOS.map((v, i) => (
|
||||||
</div>
|
<button key={v.id} onClick={() => setActiveVideo(i)} style={{
|
||||||
<div style={{ padding: '8px 10px 10px' }}>
|
display: 'flex', alignItems: 'center', gap: 14, width: '100%', cursor: 'pointer',
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink)', lineHeight: 1.35, marginBottom: 4, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
background: i === activeVideo ? '#fff' : 'transparent', border: 'none', borderRadius: 12, padding: 8,
|
||||||
{v[lang].title}
|
boxShadow: i === activeVideo ? '0 12px 30px -18px rgba(3,35,64,0.3)' : 'none',
|
||||||
</div>
|
textAlign: lang === 'fa' ? 'right' : 'left', flexDirection: lang === 'fa' ? 'row' : 'row-reverse',
|
||||||
<div style={{ fontSize: 10, color: 'var(--ink-5)' }}>{v.duration}</div>
|
}}>
|
||||||
</div>
|
<div style={{ position: 'relative', flexShrink: 0, width: 96, height: 64, borderRadius: 10, overflow: 'hidden' }}>
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── PODCAST (full width) ── */}
|
{/* ── PODCAST (full width) ── */}
|
||||||
{showPod && (
|
{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 */}
|
{/* heading */}
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 28, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24, gap: 12 }}>
|
||||||
<div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 }}>
|
<div style={{ width: 4, height: 28, background: GOLD, borderRadius: 2, order: lang === 'fa' ? 2 : 1 }} />
|
||||||
<div style={{ width: 28, height: 2, background: 'var(--red)' }} />
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<button style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink)', background: 'none', border: '1px solid var(--ink)', padding: '6px 16px', cursor: 'pointer', fontFamily: 'inherit' }}>
|
<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}
|
⊕ {t.listenAll}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* featured player (start) + episode list (end) */}
|
{/* ── featured player card (light) ── */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '380px 1fr', gap: 28, alignItems: 'start' }} className="max-lg:!grid-cols-1">
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: lang === 'fa' ? 'row' : 'row-reverse', gap: 22, alignItems: 'stretch',
|
||||||
{/* ── featured episode player card ── */}
|
background: '#efe9e1', borderRadius: 18, padding: 22, marginBottom: 32, direction: 'ltr',
|
||||||
<div style={{
|
}} className="max-md:!flex-col">
|
||||||
background: 'var(--ink)', borderRadius: 20, padding: 28, color: '#fff',
|
{/* cover + rating — first DOM child → right in RTL */}
|
||||||
boxShadow: '0 30px 70px -30px rgba(7,29,73,0.5)', position: 'relative', overflow: 'hidden',
|
<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 }} />
|
||||||
{/* soft glow */}
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||||
<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' }} />
|
<span style={{ color: GOLD, fontSize: 13, letterSpacing: 1 }}>★★★★★</span>
|
||||||
<div style={{ position: 'relative', display: 'flex', gap: 16, alignItems: 'center', marginBottom: 22 }}>
|
<span style={{ fontSize: 11, fontWeight: 700, color: NAVY }}>{pod.rating}</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── episode list ── */}
|
{/* player body — left */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<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) => (
|
{PODCASTS.map((p, i) => (
|
||||||
<button key={p.id} onClick={() => setActivePod(i)} style={{
|
<button key={p.id} onClick={() => setActivePod(i)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 14, width: '100%',
|
display: 'flex', alignItems: 'center', gap: 14, width: '100%', cursor: 'pointer',
|
||||||
padding: '12px 14px', cursor: 'pointer', borderRadius: 14,
|
background: i === activePod ? '#fff' : 'transparent', border: 'none', borderRadius: 12, padding: 10,
|
||||||
background: i === activePod ? '#fff' : 'transparent',
|
boxShadow: i === activePod ? '0 12px 30px -18px rgba(3,35,64,0.3)' : 'none',
|
||||||
border: i === activePod ? '1px solid var(--rule-thin)' : '1px solid transparent',
|
flexDirection: lang === 'fa' ? 'row-reverse' : 'row', textAlign: lang === 'fa' ? 'right' : 'left',
|
||||||
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',
|
<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' }}>
|
||||||
onMouseEnter={e => { if (i !== activePod) (e.currentTarget as HTMLElement).style.background = 'rgba(7,29,73,0.04)' }}
|
<div style={{ fontSize: 13.5, fontWeight: 900, color: NAVY, marginBottom: 5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p[lang].title}</div>
|
||||||
onMouseLeave={e => { if (i !== activePod) (e.currentTarget as HTMLElement).style.background = 'transparent' }}
|
<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 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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 10, color: GOLD, flexShrink: 0, alignSelf: 'flex-start' }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 800, color: 'var(--ink)', marginBottom: 3, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p[lang].title}</div>
|
{p.duration} ⏱
|
||||||
<div style={{ fontSize: 11, color: 'var(--ink-4)' }}>{p[lang].ep} · {p.duration}</div>
|
</span>
|
||||||
</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>}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue