Initial import: Majd Trading website

Next.js 15 site for گروه بازرگانی بین المللی آریو مجد آسیا, rebuilt from
majdtrading.com. Persian, English and Arabic; RTL and LTR.

- Pages: home, about, contact, six product categories, blog index and detail
- i18n: prose in per-locale JSON, structure in TypeScript, drift checked by
  `npm run i18n:check`. Blog stays single-language by design
- Meshkee backend: blog content, tenant resolution, session via the customer
  portal, and /meshkee/sitemap-config.json
- Live market data from livedata.ir, cached server-side once a minute
- Self-hosted fonts: IRANYekan (fa), IBM Plex Sans Arabic (ar), Space Grotesk
  and BBH Bartle (Latin)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alireza Hassani
2026-08-29 19:16:32 +03:30
co-authored by Claude Opus 5
commit 02aaf61bb6
95 changed files with 7050 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Local dev preview config
*.tsbuildinfo
.DS_Store
.claude/
.env*.local
.next/
next-env.d.ts
node_modules/
out/
tsconfig.tsbuildinfo
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

+526
View File
@@ -0,0 +1,526 @@
# Majd Trading — ARIO MAJD ASIA
Next.js site for گروه بازرگانی بین المللی آریو مجد آسیا, rebuilt from
[majdtrading.com](https://www.majdtrading.com). Phase one ships three pages in
Persian (RTL); the routing and content layers are already locale-aware so English
(LTR) and Arabic can be added without restructuring.
## Running it
```bash
npm run dev
```
Then open http://localhost:3001 — `/` redirects to `/fa`. (Port 3001 rather than
3000, which the MeshkeeApp Backend occupies on this machine; change it in
`.claude/launch.json` and the `dev` script if that is not true for you.)
`npm run build` produces the production build; `npm run start` serves it.
## Pages
| Route | Page |
| -------------- | ---------- |
| `/fa` | صفحه اصلی |
| `/fa/about` | درباره ی ما |
| `/fa/contact` | تماس با ما |
| `/fa/products/[slug]` | one page per product category (7) |
Category slugs: `oil`, `mineral`, `food`, `chemical`, `livestock`, `appliance`,
`construction`. Each is prerendered from `products.items` in the dictionary, and
the same slug selects the page, its icon, and its header-menu entry.
## Structure
```
src/
app/[locale]/ # locale segment owns <html lang> and <html dir>
layout.tsx # header + footer shell, metadata
page.tsx # homepage: slider, services, intro, products, partners, CTA
about/page.tsx # about, vision, mission, values
contact/page.tsx # contact cards, form, map
components/ # Header, Slider, Footer, PageHero, Section, ContactForm
i18n/
config.ts # locales, direction map, labels
dictionaries.ts # dictionary loader
dictionaries/fa.ts # all Persian copy
lib/site.ts # address, phones, emails, partners — shared across locales
middleware.ts # redirects un-prefixed paths to the default locale
public/
images/ # slider, logo, service icons, partner logos
fonts/ # IRANYekan (self-hosted)
```
## Design tokens
The palette is taken from the live site's `--wb-*` custom properties and is
defined once in `src/app/globals.css` under `@theme`:
| Token | Value | Use |
| --------------- | --------- | --------------------------- |
| `--color-brand` | `#1A3851` | headings, header, dark bands |
| `--color-accent`| `#FF214F` | CTAs, active state, rules |
| `--color-muted` | `#838385` | secondary text |
| `--color-brand-500`| `#3E6684`| Latin title lines on light bands |
| `--color-brand-300`| `#A8BECD`| Latin lines and rules on dark bands |
| `--color-surface`| `#F6F7F9`| alternating section bands |
| `--color-accent` | `#FF214F` | **form validation only** — see below |
Red is not a brand colour here. It appears in exactly three places, all inside
the contact form: the `*` on required fields, the inline error messages, and the
border of a field in error. Everything that used to be red — buttons, active nav,
rules, captions, hover states — is now navy, steel blue, or an invert to white on
dark bands. `grep -rn accent src` should only ever return `ContactForm.tsx`.
### Type
Three faces, all self-hosted in `public/fonts/` — no CDN call at runtime, which
matters for visitors on networks that cannot reach Google Fonts.
| Face | Where | Class |
| ---- | ----- | ----- |
| **IRANYekan** | all Persian text | body default |
| **BBH Bartle** | Latin heading lines only — section headings, page heroes, the slider | `.latin-title` / `font-display` |
| **Space Grotesk** | Latin labels and figures — card captions, the brand line, list numbers | `.latin` |
| **Space Grotesk** | phone numbers, emails, the Instagram handle | `.latin-ui` |
BBH Bartle is caps-only and ships a single weight, so `.latin-title` sets
`font-weight: 400` and never takes a bold utility — asking for one would give a
synthesised faux bold. It is also why emails do not use it: `ceo.majd@hotmail.com`
would render as `CEO.MAJD@HOTMAIL.COM`. That is what `.latin-ui` is for.
Only `.latin-ui` sets `direction: ltr`. `.latin` and `.latin-title` isolate their
run but inherit the page direction, so `text-align: start` still resolves to the
right edge under RTL. Adding `direction: ltr` to them silently left-aligns every
caption while the Persian heading beside it stays right-aligned — the same trap
applies to `start-*` / `end-*` inset utilities on such an element.
### Section headings
`SectionHeading` and `PageHero` both set the Latin line *behind* the Persian
title as a faint backdrop, echoing the hero slider. It is decorative repetition
of the heading, so it is `aria-hidden`. An optional Persian `subtitle` sits
between the title and the rule.
The backdrop is centred on the whole lockup, not on the title alone, and capped
at `clamp(1.75rem, 5.5vw, 3rem)` — sized to overhang the Persian title without
running past the section's own width. Raising that ceiling is what makes it
spill off both edges of the viewport.
`balanceLines` in `src/lib/text.ts` breaks the Latin string across at most two
rows, split where the halves come closest to equal length, so the backdrop reads
as a block behind the lockup rather than one very wide line. Strings of ten
characters or fewer stay on one row — breaking `ABOUT US` would strand `US` on a
line of its own.
It carries the `font-display` utility rather than `.latin-title`, because any
`direction: ltr` on a positioned element makes `inset-inline` and
`text-align: start` resolve against that element instead of the page — which
left-aligns the backdrop inside an RTL layout.
Flat by design: no shadows, no rounded corners. Gradients appear only as
readability scrims over photography. Section separation comes from 1px hairline
grids (`gap-px` over a `bg-brand-100` parent) and alternating background bands.
Photography is used three ways, each with a scrim so type stays legible over any
image. The hero slider and the service tiles share one treatment: the scrim rises
from the bottom edge and clears by mid-frame, so the top of every photo stays
open and the type sits in the dark lower half. The full-bleed products band uses
a vertical vignette instead — dark where the heading and chips sit, open through
the middle.
## Multi-language
Three languages for the static pages (`fa`, `en`, `ar`); the blog stays
single-language.
### Where things live
```
src/content/index.ts ids, slugs, image paths, ordering ← developer
src/i18n/locales/fa.json prose only ← translator
src/i18n/locales/en.json
src/i18n/locales/ar.json
src/i18n/types.ts the Dictionary shape
src/i18n/compose.ts joins structure to copy on the id
```
The split is by **who edits it**. Image paths and slugs are identical in every
language, so they live once in TypeScript — where slugs also keep their literal
types, which is what lets `ProductIcon name={product.slug}` fail the build if a
category has no icon behind it. Translators only ever open a JSON file of
sentences.
`src/i18n/locales/<code>.ts` is a three-line wrapper that assigns the JSON to
`Dictionary`, so a **missing** key fails the build.
### The fa → en/ar workflow
```bash
npm run i18n:check
```
Run it after editing `fa.json`. The type annotation already catches missing keys;
this catches the two things it cannot see — **stale** keys left behind after a
rename, and **arrays whose lengths have drifted** (a mission list of 14 items in
one language and 13 in another renders perfectly and is wrong).
Partner organisation names live in the locale files too, keyed by the ids in
`src/lib/site.ts` — these bodies have official names in all three languages, so
leaving them in Persian on the English page was wrong. Only the logo and link
stay structural.
### The blog is outside all of this
Posts live in `src/lib/posts.ts` with their own `lang` and `dir`. One rule:
**page chrome follows the visitor's locale, the article follows the post.** So
`/en/blog/cargo-insurance` serves an English header and footer, in Space Grotesk,
around a Persian article marked `lang="fa" dir="rtl"` and set in IRANYekan.
The homepage carousel is rendered only where the visitor's locale matches the
language of the posts (`post.lang === locale`), so `/en` and `/ar` omit the
section rather than showing Persian cards. Adding an English post later makes it
appear on `/en` with no code change.
Because one article is reachable from three locale paths, each post sets
`alternates.canonical` to its default-locale URL. Static pages instead declare
`hreflang` alternates, since those genuinely are translations of one another.
### Fonts per language
| Locale | Body face |
| ------ | --------- |
| `fa` | IRANYekan |
| `ar` | IBM Plex Sans Arabic |
| `en` | Space Grotesk |
IBM Plex Sans Arabic was chosen over IRANYekan's Arabic coverage: IRANYekan is a
Persian typeface and its Arabic shaping reads foreign. Plex is a corporate face
with a real weight range that sits comfortably beside Space Grotesk. Only the
Arabic subset is downloaded (~34KB per weight).
Two things make the switching work, and both are easy to break:
- The `[lang="…"]` rules redefine `--font-sans` **and** set `font-family`.
Redefining the variable alone is not enough — `body` declares
`font-family: var(--font-sans)` itself, so a rule on `<html>` never reaches it.
Setting `font-family` alone is not enough either — the nested blog article
needs the variable rebound for its own subtree.
- They are attribute selectors, not `:lang()`. They match only elements that
actually carry `lang` (the `<html>` element and a blog article), so everything
else inherits and Tailwind's `font-display` utility still wins on the Latin
backdrops.
`ss01` (Persian digit forms) is keyed on `[lang="fa"]`, not on direction —
Arabic is also RTL but uses different digit shapes.
### English has no Latin gloss
Every `*Latin` field is an empty string in `en.json`, and the components skip the
backdrop when it is empty. The device exists to pair a non-Latin heading with an
English one; in English it would print the heading twice. Filling those fields in
turns the backdrops back on.
## Market data
A framed panel on the homepage, in all three languages. Server-side, entirely in
this repo (`src/lib/market/`).
| File | What it is |
| ---- | ---------- |
| `displayed.ts` | **the one list to edit** — which figures appear, in order |
| `instruments.ts` | catalogue of every id the upstream publishes (39 identified) |
| `livedata.ts` | the source adapter — three parsers, see below |
| `index.ts` | caching, failure handling |
| `types.ts` | the `MarketSource` interface |
The frame has three panes: the id-based figures, the gold table, and world
exchange status. Labels are ours, translated in each locale file — only the
numbers come from upstream.
The gold table's last row is the world ounce, which is *not* part of that table
upstream — it is read from its own id (`300106`) and appended. That id is
deliberately absent from `displayed.ts`; listing it there too would print the
same figure twice in one frame. Its Toman cell shows an em dash, because
upstream publishes no Toman price for the ounce and deriving one from the gram
rate would be inventing a figure.
### Three parsers, three levels of fragility
- **Figures** (gold ounce, Brent, USDT, exchange USD/EUR) are anchored on stable
numeric ids (`s_200101`). The page is ad-heavy and its layout shifts, but an id
has a fixed meaning. Most robust.
- **Gold by purity** has no ids — it is anchored on the section heading and each
row's fixed Persian label. If upstream renames a row, that row drops out and
the others survive.
- **Exchange sessions** are anchored on `class="nma"` plus the status icon class.
Session state is taken from upstream rather than computed from opening hours,
because upstream **accounts for public holidays** — it reports the actual
reopening date, which a hours-only calculation would get wrong every holiday.
Those dates are reformatted into each reader's own calendar: `Aug 31` in English,
`شهریور ۹` in Persian, `أغسطس ٣١` in Arabic.
### Why server-side, and why 60s
livedata.ir sends no `Access-Control-Allow-Origin` (verified with an
`Origin: https://majdtrading.com` request), so a browser fetch is blocked
outright. Rendering on the server also means the upstream is hit **once per
minute for the whole site**, not once per visitor.
60s and not 30s because the upstream regenerates on the minute — measured at
`04:29:02`, `04:30:02`, `04:30:02` across samples 25s apart. Polling faster
doubles their load for no extra freshness.
### Presentation notes
Table headers and their values both align to the page's start edge, and
`dir="ltr"` sits on an inner span around each number rather than on the cell.
Putting it on the cell changes what `text-end` resolves to, which left every
header sitting on the opposite side of its own column from the values beneath it.
Upstream groups some figures (`29,136,700`) and not others (`199000`). The
component groups the ungrouped ones so the frame is internally consistent; the
digits themselves are never altered.
### Swapping the source
`index.ts` has one line — `const source: MarketSource = livedataSource`. When the
company backend exists, add `backend.ts` implementing `MarketSource` and change
that line. The component, the caching and the selection list stay as they are.
`getMarketSnapshot()` never throws: an unreachable upstream logs and returns
null, so a build never depends on a third party being up and a visitor never
sees a broken widget.
### Before this goes live
The figures are compiled by livedata.ir, whose site is funded by advertising.
The frame carries visible attribution and a link back, but **permission is still
worth getting in writing** — they sell ad space and have a contact page, so a
licence or attribution agreement is a realistic ask.
## Meshkee backend
Contract: https://api.meshkee.com/docs/website · base `https://api.meshkee.com/api/v1`
`src/lib/api.ts` holds the base URL, the tenant domain and a `apiGet` helper that
**returns null instead of throwing** — a marketing page must still render when
the API is down, so every caller degrades to hiding its section.
`GET /tenants/majdtrading.com` confirms the tenant: business id 25,
`primaryColor: "dark-blue"`, `defaultLocale: "fa"`, modules
`products, blog, finance`. Logo and favicon are still null upstream, so the site
keeps using the local logo files.
### Blog
Now served from the backend (`src/lib/blog.ts`), replacing the placeholder posts.
The two endpoints disagree in shape and both are normalised behind one type:
| | |
| --- | --- |
| `GET /tenants/{domain}/blogs` | `{ items: [...] }` |
| `GET /tenants/{domain}/blogs/{slug}` | `{ blog: {...} }` |
Bodies arrive as HTML from the Meshkee editor and are styled by `.prose-brand`
in `globals.css` rather than a typography plugin. The editor emits inline
`float`/`margin` on images, which that rule overrides so images stay in the flow.
Media comes from `*.parspack.net`, allowlisted in `next.config.ts` —
`next/image` renders an empty box for any host not listed there.
### Accounts
Sign-in happens on `customer.{domain}`; this site never sees a password and never
calls `POST /auth/login`. It reads a session token from a cookie, asks
`GET /auth/me` who it belongs to, and renders the name with the honorific plus a
dropdown (profile / sign out, and *manage website* for an administrator).
**Two things here are unverified and are the first place to look if it misbehaves:**
- **The cookie.** The API authenticates with a Bearer JWT, which portals usually
keep in localStorage — and localStorage is *not* shared across subdomains. For
this to work, the portal must write the token to a cookie scoped to the apex
(`.majdtrading.com`). Whether it does, and under what name, is not documented.
`SESSION_COOKIE` in `src/lib/auth.ts` is the single place to correct.
- **The `/auth/me` shape.** It has no response schema in the OpenAPI document and
needs a token we do not hold, so it could not be sampled. The parser is
deliberately shape-tolerant across the plausible spellings of name and role.
Until the cookie name matches, `getSession()` returns null and the header shows
the sign-in link — the correct fallback, just not the whole feature.
`auth.ts` is `server-only`; the client-safe links live in `portal.ts`. Importing
the former from a client component pulls `next/headers` into the browser bundle
and fails the build.
### The cost of a personalised header
Reading cookies in the locale layout opts **every page** into on-demand
rendering. The build table still prints ● against most routes, but that reflects
`generateStaticParams`, not prerendering: `prerender-manifest.json` lists exactly
one route (`/_not-found`). Fetch-level caching is unaffected — the market data is
still fetched once a minute for the whole site — but HTML is no longer prebuilt.
The alternative is to fetch the session client-side and keep the pages static, at
the cost of a brief signed-out flash. Worth revisiting once the cookie mechanism
is confirmed.
## SEO and sitemap
Per the Meshkee SEO brief.
**No `public/sitemap.xml` or `public/robots.txt`** — nginx proxies both to the
Meshkee API. Adding local copies would shadow the real ones.
What this site publishes instead is `GET /meshkee/sitemap-config.json`
(`src/app/meshkee/sitemap-config.json/route.ts`): the static pages the API cannot
enumerate. It is generated from the same `locales` and `productCategories`
constants the app renders from, so it cannot drift from what exists. CMS content
reaches the sitemap through the API's own child sitemaps.
*After deploying, run: business dashboard → Website → Settings → Sync sitemap
config.*
### Canonical blog URLs
`/{locale}/blog/{id}/{titleSlug}`, resolved through `GET .../blogs/by-id/{id}`.
The slug segment is decorative — the id resolves the page — and is built with
`slugify()` from the title. **The API's own `slug` field is deliberately not
used**: on real data it holds legacy values like `item-old-2945`, which the brief
rules out of public URLs. A request whose slug has drifted 307s to the canonical
form.
Two encoding traps live in that redirect, both of which surface as a 500 rather
than anything obvious:
- Next hands the route param **percent-encoded**, so it must be decoded before
comparing against a Persian slug — otherwise every request looks like a drift.
- A `Location` header may not carry raw non-ASCII, so the redirect target must be
re-encoded on the way out.
### On-page
Every page carries a unique title and meta description, and **exactly one `<h1>`**
— audited across all locales. The homepage needed one added: its hero is a
rotating slider whose captions are `h2`s, so the document had no `h1` at all. It
ships `sr-only`, keeping the outline correct without inventing a visible title
the design does not have.
## Grids that follow their data
`lgColumns()` in `src/lib/grid.ts` maps an item count onto a static
`lg:grid-cols-N` class. Tailwind extracts class names as literal strings, so
`lg:grid-cols-${n}` never reaches the stylesheet — hence the lookup rather than
interpolation.
Used by the homepage product row and the "other categories" row on each product
page. Both had drifted: removing the home appliances category left six items in a
seven-column grid, and five "others" in a six-column one, each with a dead
column at the end. Deriving the count means that cannot recur.
## Known gaps
- **The contact form has no backend.** `ContactForm.tsx` validates on the client
and then shows the success message without sending anything. Point it at a
server action or mail endpoint before launch — the spot is marked with a comment.
- **Content is homepage/about/contact only.** Products, services, news and
catalog exist on the live site as separate pages; they appear here as summary
sections, not as their own routes.
- The map on the contact page is an OpenStreetMap embed centred on Sepah Square,
Qom, with a link out beneath it.
- **Partner names are not sourced from majdtrading.com** — that site carries none
(every `title` attribute is just the URL and every `alt` is the company name).
The logo-to-link mapping in `src/lib/site.ts` is taken from its markup and is
reliable. The names are the organisations' own: ICCIMA and ISIPO were confirmed
from their sites' own titles, IRICA is legible in its logo. Qom Chamber, Iran
Fair and the ministry entry (`dotic.ir`) could not be reached from here and are
named from their logos and naming convention — worth a check before launch.
- **The backend has no published blog posts** for majdtrading.com
(`/tenants/majdtrading.com/blogs` returns `total: 0`), so the homepage carousel
is hidden and `/{locale}/blog` shows its empty state. Both fill in on their own
once posts are published — no code change.
- Home appliances was removed as a product category. The phrase still appears in
the About copy, because that is the company's own description of what it
trades, taken verbatim from majdtrading.com.
- **The product category copy is mine, not the client's.** The intro paragraph
and goods list on each of the seven pages were written for this build. They are
plausible for an Iranian trading company but are not sourced from
majdtrading.com — the client must confirm what they actually trade before
launch. Everything else on the site is their own copy.
- The homepage contact frame repeats the address, phones and email that the
footer shows directly beneath it. Fine if the frame is meant to be the closing
call to action, but it is duplication worth a decision.
## Assets
Logo, slider photography, service icons and partner logos were downloaded from
majdtrading.com into `public/images/`. IRANYekan — the same family the live site
loads from a CDN — is self-hosted in `public/fonts/`, alongside Space Grotesk.
`public/images/sample/` holds the photos from the `Image Sample/` folder,
re-encoded to sRGB JPEG at web sizes. Which slot each one fills is set in
`src/i18n/dictionaries/fa.ts` (`services.items[].image` and
`products.background`) — swapping one is a one-line change.
Landscape frames go to the full-width slots, portrait frames to the tiles, so
nothing is cropped against its grain.
| File | Slot | Source |
| ---- | ---- | ------ |
| `deck-containers.jpg` | slide 1 + products band | `premium_photo-1749672969689…` |
| `aerial-vessel.jpg` | slide 2 + service tile — IMPORT AND EXPORT | `premium_photo-1661880889658…` |
| `container-canyon.jpg` | slide 3 + service tile — TECHNICAL SERVICES | `caleb-…` |
| `port-vessel.jpg` | service tile — TRANSPORT | `delfina-iacub-…` |
| `container-stack.jpg` | service tile — DESIRED PRODUCTS | `aron-yigin-…` |
The header carries a products menu whose dropdown lists the seven categories. It
opens on hover *and* on keyboard focus, which is why the panel is hidden with
`opacity`/`visibility` rather than `display: none` — a panel that is not rendered
cannot take focus, so `group-focus-within` would never fire. Each entry targets
its own card (`#product-oil`, `#product-mineral`, …); the cards carry
`scroll-mt-28` so the sticky header does not cover the one you land on.
There are no per-category pages yet, so those links jump within the homepage. If
categories become their own routes later, the hrefs in `Header.tsx` are the only
thing to change.
The seven product categories render as a grid of cards, each with a minimal line
icon above its label. The icons live in `src/components/ProductIcon.tsx` and are
selected by the `icon` key on each entry in `products.items`; they draw in
`currentColor`, so a card recolours its icon along with its text on hover. Adding
a category means adding a shape to that file and naming it in the dictionary —
the union type will flag a name with no shape behind it.
The slider is three slides, set in `slider` in the same dictionary file. Each
slide carries a Persian `description` shown under its title, and each service
item carries one that is hidden until the tile is hovered — revealed by animating
a wrapper grid row from `0fr` to `1fr` (so no fixed height is baked in) while the
paragraph fades and slides up. Each
slide's `subtitle` is split on spaces and set one word per line as an oversized,
10%-opacity backdrop behind the Persian headline — so `"ARIO MAJD ASIA"` stacks
as ARIO / MAJD / ASIA. It is `aria-hidden`, being decorative repetition of the
heading. Note that the positioning wrapper stays in the RTL flow and only the
words carry `font-display`: putting `.latin-title` (which sets `direction: ltr`)
on the positioned element makes `start-0` resolve against *its own* direction and
anchor to the wrong edge.
### Licensing — action needed before launch
`aerial-vessel.jpg` and `deck-containers.jpg` come from the two `.avif` files,
which are **watermarked Unsplash+ preview downloads**. Both now carry slides in
the hero, where the tiled `Unsplash+` watermark is plainly visible at full width
— it is the most prominent thing on the page. These must be replaced with
licensed downloads before launch. Same filenames, no code change.
`Screenshot 2026-08-25 at 23.04.50.png` was left unused: it is a screen capture
of an Unsplash page, complete with browser UI and a tooltip, and is watermarked
as well.
`container-stack.jpg` is only 640px wide — the largest the source file offers.
It is slightly soft on a high-DPI screen in its tile; a larger re-download would
fix it.
+16
View File
@@ -0,0 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
images: {
// Blog artwork is served from the Meshkee media host, so next/image needs
// it allowlisted. Anything not listed here renders as an empty box.
remotePatterns: [
{ protocol: "https", hostname: "*.parspack.net" },
{ protocol: "https", hostname: "cdn.meshkee.com" },
{ protocol: "https", hostname: "*.meshkee.com" },
],
},
};
export default nextConfig;
+1602
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "majd-trading",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"i18n:check": "node scripts/check-i18n.mjs"
},
"dependencies": {
"next": "^15.5.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"server-only": "^0.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.0",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4.1.0",
"typescript": "^5.7.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

+66
View File
@@ -0,0 +1,66 @@
/**
* Compares every locale file against the reference locale (fa).
*
* The `Dictionary` annotation on each wrapper already fails the build on a
* MISSING key. This catches the other two cases it cannot see: keys left behind
* after a rename, and arrays whose length drifted apart (a mission list of 14
* items in one language and 13 in another renders fine and is wrong).
*
* Run after editing fa.json, before touching en/ar: npm run i18n:check
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const dir = join(here, "..", "src", "i18n", "locales");
const REFERENCE = "fa";
const OTHERS = ["en", "ar"];
const load = (l) => JSON.parse(readFileSync(join(dir, `${l}.json`), "utf8"));
/** Flattens to `a.b.c` paths; arrays report as one path plus their length. */
function paths(value, prefix = "", out = new Map()) {
if (Array.isArray(value)) {
out.set(prefix, `array:${value.length}`);
return out;
}
if (value && typeof value === "object") {
for (const [k, v] of Object.entries(value)) {
paths(v, prefix ? `${prefix}.${k}` : k, out);
}
return out;
}
out.set(prefix, typeof value);
return out;
}
const reference = paths(load(REFERENCE));
let problems = 0;
for (const locale of OTHERS) {
const current = paths(load(locale));
const missing = [...reference.keys()].filter((k) => !current.has(k));
const stale = [...current.keys()].filter((k) => !reference.has(k));
const lengths = [...reference.entries()].filter(
([k, v]) => current.has(k) && v !== current.get(k) && v.startsWith("array")
);
if (!missing.length && !stale.length && !lengths.length) {
console.log(` ${locale}: in sync with ${REFERENCE} (${reference.size} keys)`);
continue;
}
problems += missing.length + stale.length + lengths.length;
console.log(` ${locale}:`);
for (const k of missing) console.log(` missing ${k}`);
for (const k of stale) console.log(` stale ${k} (not in ${REFERENCE})`);
for (const [k, v] of lengths)
console.log(` length ${k} ${REFERENCE}=${v} ${locale}=${current.get(k)}`);
}
if (problems) {
console.error(`\ni18n check failed: ${problems} problem(s).`);
process.exit(1);
}
console.log("\ni18n check passed.");
+114
View File
@@ -0,0 +1,114 @@
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import PageHero from "@/components/PageHero";
import { Section, SectionHeading } from "@/components/Section";
import { isLocale } from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries";
import { pageImages } from "@/content";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) return {};
const t = await getDictionary(locale);
return { title: t.about.heading, description: t.about.paragraphs[0] };
}
export default async function AboutPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
const t = await getDictionary(locale);
return (
<>
<PageHero
title={t.about.heading}
latin={t.about.headingLatin}
image={pageImages.about}
/>
<Section>
<div className="grid gap-12 lg:grid-cols-[3fr_2fr]">
<div className="space-y-6">
{t.about.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-justify text-base leading-9 text-brand/80">
{paragraph}
</p>
))}
</div>
<div className="relative aspect-3/4 w-full self-start">
<Image
src={pageImages.aboutPortrait}
alt={t.meta.company}
fill
sizes="(max-width: 1024px) 100vw, 40vw"
className="object-cover"
/>
</div>
</div>
</Section>
<Section className="bg-surface">
<SectionHeading
title={t.about.vision.heading}
latin={t.about.vision.headingLatin}
subtitle={t.about.vision.subtitle}
align="start"
/>
<div className="mt-8 space-y-6">
{t.about.vision.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-justify text-base leading-9 text-brand/80">
{paragraph}
</p>
))}
</div>
</Section>
<Section>
<SectionHeading
title={t.about.mission.heading}
latin={t.about.mission.headingLatin}
subtitle={t.about.mission.subtitle}
align="start"
/>
<ol className="mt-10 grid gap-px bg-brand-100 md:grid-cols-2">
{t.about.mission.items.map((item, i) => (
<li key={item} className="flex gap-4 bg-white p-6">
<span className="latin shrink-0 text-2xl font-bold text-brand-100">
{String(i + 1).padStart(2, "0")}
</span>
<p className="text-sm leading-8 text-brand/80">{item}</p>
</li>
))}
</ol>
</Section>
<Section className="bg-brand">
<SectionHeading
title={t.about.values.heading}
latin={t.about.values.headingLatin}
subtitle={t.about.values.subtitle}
tone="light"
/>
<ul className="mt-12 flex flex-wrap justify-center gap-3">
{t.about.values.items.map((value) => (
<li
key={value}
className="border border-white/20 px-6 py-3 text-sm font-medium text-white/85 transition-colors hover:border-white hover:bg-white hover:text-brand"
>
{value}
</li>
))}
</ul>
</Section>
</>
);
}
+121
View File
@@ -0,0 +1,121 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import PageHero from "@/components/PageHero";
import Reveal from "@/components/Reveal";
import { Section } from "@/components/Section";
import { isLocale } from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries";
import { pageImages } from "@/content";
import { getPostById } from "@/lib/blog";
type Params = { locale: string; id: string; slug: string };
/** A malformed escape sequence must not take the page down. */
function safeDecode(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { locale, id } = await params;
const post = await getPostById(Number(id));
if (!post) return {};
return {
title: post.title,
description: post.abstract ?? undefined,
alternates: { canonical: `/${locale}/blog/${post.id}/${encodeURIComponent(post.slug)}` },
openGraph: post.imageUrl ? { images: [post.imageUrl], type: "article" } : undefined,
};
}
export default async function BlogPostPage({ params }: { params: Promise<Params> }) {
const { locale, id, slug } = await params;
if (!isLocale(locale)) notFound();
const numericId = Number(id);
if (!Number.isInteger(numericId)) notFound();
const post = await getPostById(numericId);
if (!post) notFound();
// The slug segment is SEO-only. If it drifts from the canonical form (an old
// link, or the title was edited), send the reader to the canonical URL.
//
// Two encoding traps here, both of which produce a 500 rather than a redirect:
// the incoming param arrives percent-ENCODED, so it must be decoded before
// comparing against a Persian slug; and a Location header may not contain raw
// non-ASCII, so the target must be encoded on the way back out.
const decodedSlug = safeDecode(slug);
if (decodedSlug !== post.slug) {
redirect(`/${locale}/blog/${post.id}/${encodeURIComponent(post.slug)}`);
}
const t = await getDictionary(locale);
const publishedAt = post.publishedAt
? new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric" }).format(
new Date(post.publishedAt)
)
: null;
return (
<>
<PageHero
title={post.title}
latin={t.blog.headingLatin}
image={post.imageUrl ?? pageImages.about}
/>
<Section>
<article className="mx-auto max-w-3xl">
<Reveal>
{publishedAt && (
<p className="text-xs text-muted">
{t.blog.publishedOn}{" "}
<time dateTime={post.publishedAt ?? undefined} className="text-brand/70">
{publishedAt}
</time>
{post.author && <span className="text-brand/70"> · {post.author}</span>}
</p>
)}
<span className="mt-5 block h-1 w-16 bg-brand" />
{post.abstract && (
<p className="mt-8 text-justify text-base font-medium leading-9 text-brand">
{post.abstract}
</p>
)}
{post.bodyHtml && (
/* Body arrives as HTML from the Meshkee editor — authored in the
client's own dashboard, trusted the way a CMS body is.
`.prose-brand` in globals.css styles it without a plugin. */
<div
className="prose-brand mt-8"
dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
/>
)}
</Reveal>
</article>
<div className="mx-auto mt-12 max-w-3xl border-t border-brand-100 pt-8">
<Link
href={`/${locale}/blog`}
className="text-sm font-bold text-brand transition-colors hover:text-brand-500"
>
{t.blog.backToList}
</Link>
</div>
</Section>
</>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import PageHero from "@/components/PageHero";
import Reveal from "@/components/Reveal";
import { Section } from "@/components/Section";
import { isLocale } from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries";
import { pageImages } from "@/content";
import { getPosts } from "@/lib/blog";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) return {};
const t = await getDictionary(locale);
return { title: t.blog.heading, description: t.blog.subtitle };
}
export default async function BlogIndexPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
const t = await getDictionary(locale);
const posts = await getPosts(24);
const formatDate = (value: string | null) =>
value
? new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric" }).format(
new Date(value)
)
: null;
return (
<>
<PageHero title={t.blog.heading} latin={t.blog.headingLatin} image={pageImages.about} />
<Section>
{posts.length === 0 ? (
<p className="text-center text-sm leading-8 text-brand/60">{t.blog.empty}</p>
) : (
<div className="grid gap-px bg-brand-100 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, i) => (
<Reveal key={post.id} delay={i * 70} className="bg-white">
<Link href={`/${locale}/blog/${post.id}/${post.slug}`} className="group flex h-full flex-col">
<div className="relative aspect-16/10 w-full overflow-hidden bg-brand-900">
{post.imageUrl && (
<Image
src={post.imageUrl}
alt=""
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
)}
</div>
<div className="flex flex-1 flex-col p-6">
{post.publishedAt && (
<time dateTime={post.publishedAt} className="text-xs text-muted">
{formatDate(post.publishedAt)}
</time>
)}
<h2 className="mt-3 text-base font-bold leading-8 text-brand">{post.title}</h2>
{post.abstract && (
<p className="mt-3 line-clamp-3 flex-1 text-sm leading-7 text-brand/65">
{post.abstract}
</p>
)}
</div>
</Link>
</Reveal>
))}
</div>
)}
</Section>
</>
);
}
+188
View File
@@ -0,0 +1,188 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import ContactForm from "@/components/ContactForm";
import PageHero from "@/components/PageHero";
import { Section, SectionHeading } from "@/components/Section";
import { isLocale } from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries";
import { pageImages } from "@/content";
import { site } from "@/lib/site";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) return {};
const t = await getDictionary(locale);
return { title: t.contact.heading, description: site.address };
}
function InfoCard({
title,
latin,
icon,
children,
}: {
title: string;
latin: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<article className="bg-white p-8">
<div className="grid h-14 w-14 place-items-center bg-brand-100 text-brand">{icon}</div>
<h2 className="mt-6 text-base font-bold text-brand">{title}</h2>
<span className="latin mt-1 block text-[11px] text-muted">{latin}</span>
<div className="mt-4 space-y-1.5 text-sm leading-8 text-brand/75">{children}</div>
</article>
);
}
export default async function ContactPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
const t = await getDictionary(locale);
return (
<>
<PageHero
title={t.contact.heading}
latin={t.contact.headingLatin}
image={pageImages.contact}
/>
<Section className="bg-surface">
<div className="grid gap-px bg-brand-100 sm:grid-cols-2 lg:grid-cols-4">
<InfoCard
title={t.contact.addressHeading}
latin={t.contact.addressLatin}
icon={
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 21s7-5.6 7-11a7 7 0 10-14 0c0 5.4 7 11 7 11z"
stroke="currentColor"
strokeWidth="1.7"
/>
<circle cx="12" cy="10" r="2.6" stroke="currentColor" strokeWidth="1.7" />
</svg>
}
>
<p>{site.address}</p>
</InfoCard>
<InfoCard
title={t.contact.emailHeading}
latin={t.contact.emailLatin}
icon={
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" strokeWidth="1.7" />
<path d="M3.5 6.5L12 13l8.5-6.5" stroke="currentColor" strokeWidth="1.7" />
</svg>
}
>
{site.emails.map((email) => (
<a
key={email}
href={`mailto:${email}`}
dir="ltr"
className="latin-ui block text-start transition-colors hover:text-brand"
>
{email}
</a>
))}
</InfoCard>
<InfoCard
title={t.contact.phoneHeading}
latin={t.contact.phoneLatin}
icon={
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M6.2 3.5h3l1.5 4-2 1.4a12.5 12.5 0 006.4 6.4l1.4-2 4 1.5v3c0 1-.8 1.8-1.8 1.7C10.6 19.9 4.1 13.4 3.5 5.3c-.1-1 .7-1.8 1.7-1.8z"
stroke="currentColor"
strokeWidth="1.7"
/>
</svg>
}
>
{site.phones.map((phone, i) => (
<a
key={phone}
href={`tel:${phone}`}
dir="ltr"
className="latin-ui block text-start transition-colors hover:text-brand"
>
{site.phonesDisplay[i]}
</a>
))}
</InfoCard>
<InfoCard
title={t.contact.socialHeading}
latin={t.contact.socialLatin}
icon={
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="2.5" y="2.5" width="19" height="19" rx="5" stroke="currentColor" strokeWidth="1.7" />
<circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.7" />
<circle cx="17.6" cy="6.4" r="1.2" fill="currentColor" />
</svg>
}
>
<a
href={site.instagram.url}
target="_blank"
rel="noopener noreferrer"
className="latin-ui block text-start transition-colors hover:text-brand"
>
{site.instagram.handle}
</a>
</InfoCard>
</div>
</Section>
<Section>
<div className="grid gap-12 lg:grid-cols-[3fr_2fr]">
<div>
<SectionHeading
title={t.contact.lead}
latin={t.contact.leadLatin}
subtitle={t.contact.leadSubtitle}
align="start"
/>
<div className="mt-10">
<ContactForm t={t} />
</div>
</div>
<div className="self-start">
<iframe
title={site.address}
src={site.map.embed}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="h-96 w-full border border-brand-100 bg-surface"
/>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm leading-8 text-brand/75">{site.address}</p>
<a
href={site.map.link}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-bold text-brand transition-colors hover:text-brand-500"
>
{t.contact.viewOnMap}
</a>
</div>
</div>
</div>
</Section>
</>
);
}
+89
View File
@@ -0,0 +1,89 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import Header from "@/components/Header";
import { getSession } from "@/lib/auth";
import Footer from "@/components/Footer";
import { locales, localeDirection, defaultLocale, isLocale } from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries";
import { site } from "@/lib/site";
import "@/app/globals.css";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) return {};
const t = await getDictionary(locale);
return {
metadataBase: new URL(site.url),
title: {
default: `${t.meta.company} | ${t.meta.companyLatin}`,
template: `%s | ${t.meta.companyShort}`,
},
description: t.meta.description,
icons: { icon: "/images/brand/logo.png" },
// Static pages exist in all three languages; tell search engines they are
// translations of one another rather than duplicates. (Blog posts override
// this with a single canonical — they are not translated.)
alternates: {
languages: Object.fromEntries([
...locales.map((code) => [code, `/${code}`]),
["x-default", `/${defaultLocale}`],
]),
},
openGraph: {
title: t.meta.company,
description: t.meta.description,
locale,
type: "website",
images: ["/images/slider/slider1.jpg"],
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
const t = await getDictionary(locale);
const dir = localeDirection[locale];
const session = await getSession();
return (
<html lang={locale} dir={dir}>
<head>
{/* Scroll-reveal starts hidden; without JS it must never stay that way. */}
<noscript>
<style>{`[data-reveal]{opacity:1 !important;transform:none !important}`}</style>
</noscript>
</head>
<body className="flex min-h-screen flex-col">
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:m-3 focus:rounded focus:bg-brand focus:px-4 focus:py-2 focus:text-white"
>
{t.common.skipToContent}
</a>
<Header locale={locale} t={t} session={session} />
<main id="main" className="flex-1">
{children}
</main>
<Footer locale={locale} t={t} />
</body>
</html>
);
}
+362
View File
@@ -0,0 +1,362 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import Slider from "@/components/Slider";
import BlogCarousel from "@/components/BlogCarousel";
import MarketFrame from "@/components/MarketFrame";
import ProductIcon from "@/components/ProductIcon";
import Reveal from "@/components/Reveal";
import { Section, SectionHeading } from "@/components/Section";
import { isLocale } from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries";
import { composeCategories, composeServices, composeSlides } from "@/i18n/compose";
import { pageImages, productsBackground } from "@/content";
import { lgColumns } from "@/lib/grid";
import { getMarketSnapshot } from "@/lib/market";
import { getPosts } from "@/lib/blog";
import { site } from "@/lib/site";
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
const t = await getDictionary(locale);
// Posts come from the Meshkee backend and are not translated — the section is
// simply absent when nothing is published.
const posts = await getPosts(9);
// null when nothing is selected in market/displayed.ts, or the upstream is
// unreachable — the strip is then simply absent.
const market = await getMarketSnapshot();
const slides = composeSlides(t);
const serviceTiles = composeServices(t);
const categories = composeCategories(t);
return (
<>
{/* The hero is a rotating slider whose captions are h2s, so the page had
no h1 at all. One shipped here, off-screen, keeps the document outline
correct without inventing a visible title the design does not have. */}
<h1 className="sr-only">
{t.meta.company} — {t.meta.tagline}
</h1>
<Slider slides={slides} t={t} />
{market && (
<Section className="bg-surface">
<SectionHeading
title={t.market.heading}
latin={t.market.headingLatin}
subtitle={t.market.subtitle}
/>
<div className="mt-12">
<MarketFrame snapshot={market} t={t} locale={locale} />
</div>
</Section>
)}
{/* Services — photo tiles under a navy scrim */}
<Section>
<SectionHeading
title={t.services.heading}
latin={t.services.headingLatin}
subtitle={t.services.subtitle}
/>
<div className="mt-12 grid gap-px bg-brand-100 sm:grid-cols-2 lg:grid-cols-4">
{serviceTiles.map((item, i) => (
<Reveal
as="article"
key={item.id}
delay={i * 110}
className="group relative aspect-4/5 overflow-hidden bg-brand-900"
>
<Image
src={item.image}
alt=""
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
{/* Scrim rises from the bottom edge, matching the hero. */}
<div className="absolute inset-0 bg-linear-to-t from-brand-900/95 from-0% via-brand-900/60 via-30% to-brand-900/15" />
<div className="absolute inset-x-0 bottom-0 p-7">
<span className="latin block text-[11px] font-semibold text-brand-300">
{item.latin}
</span>
<h3 className="mt-2 text-lg font-bold leading-8 text-white">{item.title}</h3>
{/* Revealed on hover. The 0fr -> 1fr grid row animates the
height without hard-coding one, so any length of copy works. */}
<div className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-500 ease-out group-hover:grid-rows-[1fr]">
<div className="overflow-hidden">
<p className="mt-3 translate-y-3 text-sm leading-7 text-white/75 opacity-0 transition-[opacity,transform] duration-500 ease-out group-hover:translate-y-0 group-hover:opacity-100">
{item.description}
</p>
</div>
</div>
<span className="mt-4 block h-0.5 w-10 bg-brand-300 transition-all duration-500 group-hover:w-20 group-hover:bg-white" />
</div>
</Reveal>
))}
</div>
</Section>
{/* Intro */}
<Section className="bg-surface">
<div className="grid items-center gap-12 lg:grid-cols-2">
<Reveal>
<SectionHeading
title={t.home.introHeading}
latin={t.home.introLatin}
align="start"
/>
<p className="mt-8 text-justify text-base leading-9 text-brand/80">
{t.home.introBody}
</p>
<Link
href={`/${locale}/about`}
className="mt-8 inline-block bg-brand px-7 py-3 text-sm font-bold text-white transition-colors hover:bg-brand-700"
>
{t.home.introCta}
</Link>
</Reveal>
<Reveal delay={180} className="relative">
{/* Flat block peeking out from behind the photo. */}
<span className="absolute -bottom-4 -start-4 hidden h-32 w-32 bg-brand lg:block" />
<div className="relative aspect-4/3 w-full">
<Image
src={pageImages.intro}
alt={t.meta.company}
fill
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover"
/>
</div>
</Reveal>
</div>
</Section>
{/* Products — full-bleed photo band under a navy scrim */}
<section id="products" className="relative overflow-hidden bg-brand-900">
<Image
src={productsBackground}
alt=""
fill
sizes="100vw"
className="object-cover"
/>
{/* Vignette: dark at the edges where the type sits, open through the middle. */}
<div className="absolute inset-0 bg-linear-to-b from-brand-900/92 via-brand-900/55 to-brand-900/92" />
<div className="relative mx-auto max-w-7xl px-4 py-16 sm:px-6 sm:py-20 lg:px-8">
<SectionHeading
title={t.products.heading}
latin={t.products.headingLatin}
subtitle={t.products.lead}
tone="light"
/>
{/* Column count follows the data, so the row always fills the width. */}
<ul
className={`mt-12 grid grid-cols-2 gap-3 sm:grid-cols-3 ${lgColumns(
categories.length
)}`}
>
{categories.map((product, i) => (
<Reveal as="li" key={product.slug} delay={i * 70}>
<Link
href={`/${locale}/products/${product.slug}`}
className="flex h-full flex-col items-center gap-4 border border-white/25 px-3 py-7 text-center text-white/85 transition-colors hover:border-white hover:bg-white hover:text-brand"
>
{/* Inherits currentColor, so it flips to navy with the card. */}
<ProductIcon name={product.slug} />
<span className="text-xs font-medium leading-6 sm:text-sm">
{product.title}
</span>
</Link>
</Reveal>
))}
</ul>
</div>
</section>
{/* Partners */}
<Section className="bg-surface">
<SectionHeading
title={t.home.partnersHeading}
latin={t.home.partnersLatin}
subtitle={t.home.partnersSubtitle}
/>
<div className="mt-12 grid grid-cols-2 gap-px bg-brand-100 sm:grid-cols-3 lg:grid-cols-6">
{site.partners.map((partner, i) => {
const name = t.partners[partner.id];
return (
<Reveal key={partner.id} delay={i * 90} className="group bg-white">
<a
href={partner.url}
target="_blank"
rel="noopener noreferrer"
className="flex h-full flex-col items-center justify-start gap-4 p-6 text-center"
>
<Image
src={partner.logo}
alt={name}
width={110}
height={60}
className="max-h-14 w-auto object-contain grayscale transition duration-500 group-hover:grayscale-0"
/>
<span className="text-xs leading-6 text-brand/65 transition-colors group-hover:text-brand">
{name}
</span>
</a>
</Reveal>
);
})}
</div>
</Section>
{/* Blog — only in the language the posts are written in. */}
{posts.length > 0 && (
<Section>
<SectionHeading
title={t.blog.heading}
latin={t.blog.headingLatin}
subtitle={t.blog.subtitle}
/>
<div className="mt-12">
<BlogCarousel posts={posts} locale={locale} t={t} />
</div>
<div className="mt-10 text-center">
<Link
href={`/${locale}/blog`}
className="inline-block border border-brand px-7 py-3 text-sm font-bold text-brand transition-colors hover:bg-brand hover:text-white"
>
{t.blog.viewAll}
</Link>
</div>
</Section>
)}
{/* Contact frame */}
<section className="bg-brand px-4 py-16 sm:px-6 sm:py-20 lg:px-8">
<Reveal className="mx-auto max-w-7xl border border-white/25 p-8 sm:p-12">
<div className="grid gap-10 lg:grid-cols-[3fr_2fr] lg:items-center lg:gap-14">
<div>
<h2 className="text-2xl font-bold text-white sm:text-3xl">
{t.home.ctaHeading}
</h2>
<p className="mt-4 max-w-xl text-sm leading-8 text-white/70">
{t.home.ctaBody}
</p>
<Link
href={`/${locale}/contact`}
className="mt-8 inline-block bg-white px-8 py-4 text-sm font-bold text-brand transition-colors hover:bg-brand-100"
>
{t.home.ctaButton}
</Link>
</div>
<ul className="space-y-6 border-t border-white/15 pt-8 text-sm lg:border-s lg:border-t-0 lg:pt-0 lg:ps-14">
<li className="flex gap-4">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="mt-1 shrink-0 text-brand-300"
>
<path
d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z"
stroke="currentColor"
strokeWidth="1.5"
/>
<circle cx="12" cy="10" r="2.6" stroke="currentColor" strokeWidth="1.5" />
</svg>
<div>
<span className="block text-xs text-brand-300">
{t.footer.addressHeading}
</span>
<span className="mt-1 block leading-7 text-white/80">{site.address}</span>
</div>
</li>
<li className="flex gap-4">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="mt-1 shrink-0 text-brand-300"
>
<path
d="M6.2 3.5h3l1.5 4-2 1.4a12.5 12.5 0 0 0 6.4 6.4l1.4-2 4 1.5v3c0 1-.8 1.8-1.8 1.7C10.6 19.9 4.1 13.4 3.5 5.3c-.1-1 .7-1.8 1.7-1.8z"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
<div>
<span className="block text-xs text-brand-300">
{t.footer.phoneHeading}
</span>
{site.phones.map((phone, i) => (
<a
key={phone}
href={`tel:${phone}`}
dir="ltr"
className="latin-ui mt-1 block text-start text-white/80 transition-colors hover:text-white"
>
{site.phonesDisplay[i]}
</a>
))}
</div>
</li>
<li className="flex gap-4">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="mt-1 shrink-0 text-brand-300"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path d="M3.5 6.5 12 13l8.5-6.5" stroke="currentColor" strokeWidth="1.5" />
</svg>
<div>
<span className="block text-xs text-brand-300">
{t.footer.emailHeading}
</span>
<a
href={`mailto:${site.emails[0]}`}
dir="ltr"
className="latin-ui mt-1 block text-start text-white/80 transition-colors hover:text-white"
>
{site.emails[0]}
</a>
</div>
</li>
</ul>
</div>
</Reveal>
</section>
</>
);
}
+134
View File
@@ -0,0 +1,134 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import PageHero from "@/components/PageHero";
import ProductIcon from "@/components/ProductIcon";
import Reveal from "@/components/Reveal";
import { Section, SectionHeading } from "@/components/Section";
import { locales, isLocale } from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries";
import { composeCategories, composeCategory } from "@/i18n/compose";
import { productCategories } from "@/content";
import { lgColumns } from "@/lib/grid";
type Params = { locale: string; slug: string };
export function generateStaticParams() {
return locales.flatMap((locale) =>
productCategories.map((item) => ({ locale, slug: item.slug }))
);
}
async function findProduct(locale: string, slug: string) {
if (!isLocale(locale)) return null;
const t = await getDictionary(locale);
const product = composeCategory(t, slug);
return product ? { t, product } : null;
}
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { locale, slug } = await params;
const found = await findProduct(locale, slug);
if (!found) return {};
return { title: found.product.title, description: found.product.intro };
}
export default async function ProductCategoryPage({
params,
}: {
params: Promise<Params>;
}) {
const { locale, slug } = await params;
const found = await findProduct(locale, slug);
if (!found) notFound();
const { t, product } = found;
const others = composeCategories(t).filter((item) => item.slug !== product.slug);
return (
<>
<PageHero title={product.title} latin={product.latin} image={product.image} />
<Section>
<div className="grid gap-12 lg:grid-cols-[3fr_2fr]">
<Reveal>
<ProductIcon name={product.slug} className="h-12 w-12 text-brand" />
<p className="mt-6 text-justify text-base leading-9 text-brand/80">
{product.intro}
</p>
<p className="mt-6 border-s-2 border-brand-100 ps-5 text-sm leading-8 text-brand/60">
{t.products.servicesNote}
</p>
</Reveal>
<Reveal delay={150}>
<SectionHeading
title={t.products.goodsHeading}
latin={t.products.goodsLatin}
align="start"
/>
<ul className="mt-8 grid grid-cols-2 gap-px bg-brand-100">
{product.goods.map((item) => (
<li key={item} className="bg-white px-4 py-3.5 text-sm text-brand/80">
{item}
</li>
))}
</ul>
</Reveal>
</div>
</Section>
{/* Other categories */}
<Section className="bg-surface">
<SectionHeading title={t.products.otherHeading} latin={t.products.otherLatin} />
{/* One fewer than the full set — the current category is excluded. */}
<div
className={`mt-12 grid grid-cols-2 gap-px bg-brand-100 sm:grid-cols-3 ${lgColumns(
others.length
)}`}
>
{others.map((item, i) => (
<Reveal key={item.slug} delay={i * 80} className="bg-white">
<Link
href={`/${locale}/products/${item.slug}`}
className="group flex h-full flex-col items-center gap-3 p-6 text-center"
>
<ProductIcon
name={item.slug}
className="h-9 w-9 text-brand/70 transition-colors group-hover:text-brand"
/>
<span className="text-xs leading-6 text-brand/70 transition-colors group-hover:text-brand">
{item.title}
</span>
</Link>
</Reveal>
))}
</div>
</Section>
{/* Inquiry */}
<section className="bg-brand px-4 py-16 sm:px-6 sm:py-20 lg:px-8">
<Reveal className="mx-auto flex max-w-7xl flex-col items-center gap-6 border border-white/25 p-8 text-center sm:p-12 lg:flex-row lg:justify-between lg:text-start">
<div>
<h2 className="text-2xl font-bold text-white sm:text-3xl">
{t.products.inquiryHeading}
</h2>
<p className="mt-3 max-w-xl text-sm leading-8 text-white/70">
{t.products.inquiryBody}
</p>
</div>
<Link
href={`/${locale}/contact`}
className="shrink-0 bg-white px-8 py-4 text-sm font-bold text-brand transition-colors hover:bg-brand-100"
>
{t.products.inquiryButton}
</Link>
</Reveal>
</section>
</>
);
}
+260
View File
@@ -0,0 +1,260 @@
@import "tailwindcss";
/* ---- IRANYekan, self-hosted (same family the current majdtrading.com uses) ---- */
@font-face {
font-family: "IRANYekan";
src: url("/fonts/iran-yekan-light.ttf") format("truetype");
font-weight: 300;
font-display: swap;
}
@font-face {
font-family: "IRANYekan";
src: url("/fonts/iran-yekan-regular.ttf") format("truetype");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "IRANYekan";
src: url("/fonts/iran-yekan-medium.ttf") format("truetype");
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "IRANYekan";
src: url("/fonts/iran-yekan-bold.ttf") format("truetype");
font-weight: 700;
font-display: swap;
}
@font-face {
font-family: "IRANYekan";
src: url("/fonts/iran-yekan-extrabold.ttf") format("truetype");
font-weight: 800;
font-display: swap;
}
@font-face {
font-family: "IRANYekan";
src: url("/fonts/iran-yekan-black.ttf") format("truetype");
font-weight: 900;
font-display: swap;
}
/* ---- IBM Plex Sans Arabic: Arabic body face. IRANYekan covers Arabic script
but is a Persian typeface and reads foreign in Arabic. ---- */
@font-face {
font-family: "IBM Plex Sans Arabic";
src: url("/fonts/plex-arabic-400.woff2") format("woff2");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "IBM Plex Sans Arabic";
src: url("/fonts/plex-arabic-500.woff2") format("woff2");
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "IBM Plex Sans Arabic";
src: url("/fonts/plex-arabic-600.woff2") format("woff2");
font-weight: 600;
font-display: swap;
}
@font-face {
font-family: "IBM Plex Sans Arabic";
src: url("/fonts/plex-arabic-700.woff2") format("woff2");
font-weight: 700;
font-display: swap;
}
/* ---- BBH Bartle: display face for the Latin captions. Caps-only, one weight,
so never ask it for bold — it would be synthesised. ---- */
@font-face {
font-family: "BBH Bartle";
src: url("/fonts/bbh-bartle-latin.woff2") format("woff2");
font-weight: 400;
font-display: swap;
}
/* ---- Space Grotesk, self-hosted (one variable file covers 400-700) ---- */
@font-face {
font-family: "Space Grotesk";
src: url("/fonts/space-grotesk-latin.woff2") format("woff2");
font-weight: 300 700;
font-display: swap;
}
@theme {
/* Palette lifted from majdtrading.com's --wb-* custom properties */
--color-brand: #1a3851;
--color-brand-700: #163045;
--color-brand-900: #0f2233;
--color-brand-100: #e8edf2;
/* Steel blues carry the accents that used to be red. */
--color-brand-300: #a8becd;
--color-brand-500: #3e6684;
/* Red is reserved for form validation only — required marks, errors. */
--color-accent: #ff214f;
/* Market movement. Semantic, like validation red — not brand colour. */
--color-up: #1f7a4d;
--color-down: #c0392b;
--color-muted: #838385;
--color-surface: #f6f7f9;
--font-sans: "IRANYekan", "Segoe UI", Tahoma, system-ui, sans-serif;
--font-fa: "IRANYekan", "Segoe UI", Tahoma, system-ui, sans-serif;
--font-ar: "IBM Plex Sans Arabic", "IRANYekan", Tahoma, system-ui, sans-serif;
--font-en: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
--font-latin: "Space Grotesk", "Segoe UI", Tahoma, system-ui, sans-serif;
--font-display: "BBH Bartle", "Space Grotesk", "Segoe UI", Tahoma, system-ui, sans-serif;
}
html {
scroll-behavior: smooth;
}
body {
background-color: #ffffff;
color: var(--color-brand);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Body face follows the language of the subtree, not the page.
These selectors match only elements that actually carry `lang` — <html>, and
a blog article whose language differs from the page around it. Everything
else inherits, which leaves Tailwind's font utilities free to win where they
are applied (the Latin backdrops use `font-display`).
They redefine --font-sans rather than only setting font-family: `body` sets
`font-family: var(--font-sans)` itself, so a rule on <html> alone would never
reach it. */
[lang="fa"] {
--font-sans: var(--font-fa);
font-family: var(--font-sans);
/* Persian digits read better than Latin ones in Farsi copy. Keyed on language,
not direction — Arabic uses different digit forms. */
font-feature-settings: "ss01";
}
[lang="ar"] {
--font-sans: var(--font-ar);
font-family: var(--font-sans);
}
[lang="en"] {
--font-sans: var(--font-en);
font-family: var(--font-sans);
}
/* Latin text stays LTR even inside an RTL document.
.latin-title — BBH Bartle. Reserved for heading lockups only.
.latin — Space Grotesk, uppercase. Card labels, figures, brand line.
.latin-ui — Space Grotesk, natural case. Phones, emails, handle. */
/* Only `.latin-ui` forces LTR — its strings carry neutrals (+, (), @, .) that
need it. `.latin` and `.latin-title` isolate the run but keep the inherited
direction, so `text-align: start` still resolves to the right edge in RTL.
Setting `direction: ltr` here would silently left-align every caption. */
.latin-title,
.latin {
unicode-bidi: isolate;
}
.latin-ui {
direction: ltr;
unicode-bidi: isolate;
}
/* Titles only — the English line that sits with a section or page heading. */
.latin-title {
font-family: var(--font-display);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.16em;
}
/* Every other English label: card captions, figures, the brand line. */
.latin {
font-family: var(--font-latin);
text-transform: uppercase;
letter-spacing: 0.16em;
}
/* Strings that must keep their case and stay legible: phones, emails, handle. */
.latin-ui {
font-family: var(--font-latin);
letter-spacing: 0.06em;
}
/* Blog bodies arrive as HTML from the Meshkee editor. Styled here rather than
with a typography plugin, so the output matches the rest of the site. */
.prose-brand {
color: color-mix(in srgb, var(--color-brand) 80%, transparent);
line-height: 2.25;
text-align: justify;
}
.prose-brand > * + * {
margin-top: 1.5rem;
}
.prose-brand h2,
.prose-brand h3,
.prose-brand h4 {
color: var(--color-brand);
font-weight: 700;
line-height: 1.8;
margin-top: 2.5rem;
}
.prose-brand h2 { font-size: 1.25rem; }
.prose-brand h3 { font-size: 1.1rem; }
.prose-brand a {
color: var(--color-brand);
text-decoration: underline;
text-underline-offset: 3px;
}
.prose-brand ul,
.prose-brand ol {
padding-inline-start: 1.5rem;
}
.prose-brand ul { list-style: disc; }
.prose-brand ol { list-style: decimal; }
.prose-brand li + li { margin-top: 0.5rem; }
.prose-brand img {
height: auto;
max-width: 100%;
/* The editor emits inline float/margin styles; keep images in the flow. */
display: block !important;
float: none !important;
margin-inline: auto !important;
}
.prose-brand table {
display: block;
overflow-x: auto;
width: 100%;
}
.prose-brand blockquote {
border-inline-start: 2px solid var(--color-brand-100);
padding-inline-start: 1.25rem;
color: color-mix(in srgb, var(--color-brand) 60%, transparent);
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { locales } from "@/i18n/config";
import { productCategories } from "@/content";
import { TENANT_DOMAIN } from "@/lib/api";
/**
* Static-page manifest for the Meshkee sitemap builder.
*
* `/sitemap.xml` and `/robots.txt` are NOT served from here — nginx proxies both
* to the Meshkee API, which is why no copies exist in `public/`. This route
* publishes only the static pages the API cannot know about; CMS content (blog
* posts, products) reaches the sitemap through the API's own child sitemaps.
*
* Derived from the same route constants the app renders from, so it cannot fall
* out of step with what actually exists. Nothing auth-related is listed — sign
* in lives on the customer portal, not on this domain.
*
* After deploying: business dashboard → Website → Settings → Sync sitemap config.
*/
export const dynamic = "force-static";
type StaticPage = { path: string; changefreq?: string; priority?: number };
export function GET() {
const staticPages: StaticPage[] = [];
for (const locale of locales) {
staticPages.push({ path: `/${locale}`, changefreq: "daily", priority: 1.0 });
staticPages.push({ path: `/${locale}/about`, changefreq: "monthly", priority: 0.6 });
staticPages.push({ path: `/${locale}/contact`, changefreq: "monthly", priority: 0.5 });
staticPages.push({ path: `/${locale}/blog`, changefreq: "daily", priority: 0.7 });
// Category pages are ours, not CMS products — the API cannot enumerate them.
for (const category of productCategories) {
staticPages.push({
path: `/${locale}/products/${category.slug}`,
changefreq: "monthly",
priority: 0.8,
});
}
}
return NextResponse.json({
baseUrl: `https://${TENANT_DOMAIN}`,
staticPages,
});
}
+87
View File
@@ -0,0 +1,87 @@
import Link from "next/link";
import type { Dictionary } from "@/i18n/dictionaries";
import { portalLinks, type Session } from "@/lib/portal";
/**
* Signed out: a link to the customer portal. Signed in: the person's name with
* the honorific, and a dropdown.
*
* Sign-in and sign-out both happen on the portal — this site never handles a
* password. Opens on hover and on keyboard focus, so the panel is hidden with
* opacity/visibility rather than `display: none` (an unrendered panel cannot
* take focus, and `group-focus-within` would never fire).
*/
export default function AccountMenu({
session,
t,
}: {
session: Session | null;
t: Dictionary;
}) {
// Signed out: icon only. The label lives in aria-label/title so it is still
// announced and still shows on hover.
if (!session) {
return (
<a
href={portalLinks.login}
aria-label={t.account.login}
title={t.account.login}
className="grid h-9 w-9 place-items-center text-brand/65 transition-colors hover:text-brand"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.6" />
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" stroke="currentColor" strokeWidth="1.6" />
</svg>
</a>
);
}
const items = [
{ href: portalLinks.profile, label: t.account.profile },
// Only an administrator sees this one.
...(session.isAdmin ? [{ href: portalLinks.admin, label: t.account.manageWebsite }] : []),
{ href: portalLinks.logout, label: t.account.logout },
];
return (
<div className="group relative flex h-full items-center">
<button
type="button"
aria-label={t.account.menu}
className="flex items-center gap-1.5 px-2 py-2 text-[13px] font-medium text-brand transition-colors group-hover:text-brand-500 group-focus-within:text-brand-500"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.6" />
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" stroke="currentColor" strokeWidth="1.6" />
</svg>
{/* "علی عزیز" — the honorific is empty in English, so no stray space. */}
<span>
{session.displayName}
{t.account.honorific ? ` ${t.account.honorific}` : ""}
</span>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="transition-transform duration-200 group-hover:rotate-180 group-focus-within:rotate-180"
>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.5" />
</svg>
</button>
<div className="invisible absolute end-0 top-full z-50 min-w-44 -translate-y-1 border border-brand-100 bg-white py-2 opacity-0 transition duration-200 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className="block px-5 py-2.5 text-[13px] text-brand/70 transition-colors hover:bg-surface hover:text-brand"
>
{item.label}
</Link>
))}
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import type { Locale } from "@/i18n/config";
import type { Dictionary } from "@/i18n/dictionaries";
import type { BlogPost } from "@/lib/blog";
/**
* Horizontal post carousel.
*
* Scrolling is native (scroll-snap) rather than a transformed track, so it works
* with a trackpad, a touch swipe and the keyboard for free. The arrows just call
* `scrollBy`, flipped for RTL where scrollLeft runs negative.
*/
export default function BlogCarousel({
posts,
locale,
t,
}: {
posts: BlogPost[];
locale: Locale;
t: Dictionary;
}) {
const trackRef = useRef<HTMLUListElement>(null);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
const sync = useCallback(() => {
const track = trackRef.current;
if (!track) return;
// scrollLeft runs negative in RTL, so compare on distance travelled.
const travelled = Math.abs(track.scrollLeft);
const max = track.scrollWidth - track.clientWidth;
setAtStart(travelled < 8);
setAtEnd(travelled > max - 8);
}, []);
useEffect(() => {
sync();
window.addEventListener("resize", sync);
return () => window.removeEventListener("resize", sync);
}, [sync]);
const step = (direction: 1 | -1) => {
const track = trackRef.current;
if (!track) return;
const card = track.querySelector("li");
const amount = card ? card.getBoundingClientRect().width + 24 : track.clientWidth * 0.8;
const rtl = getComputedStyle(track).direction === "rtl";
track.scrollBy({ left: amount * direction * (rtl ? -1 : 1), behavior: "smooth" });
};
const formatDate = (value: string | null) =>
value
? new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric" }).format(
new Date(value)
)
: null;
return (
<div className="relative">
<ul
ref={trackRef}
onScroll={sync}
className="flex snap-x snap-mandatory gap-6 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{posts.map((post) => (
<li key={post.id} className="w-[85%] shrink-0 snap-start sm:w-[48%] lg:w-[31.5%]">
<Link
href={`/${locale}/blog/${post.id}/${post.slug}`}
className="group flex h-full flex-col border border-brand-100 bg-white transition-colors hover:border-brand"
>
<div className="relative aspect-16/10 w-full overflow-hidden bg-brand-900">
{post.imageUrl && (
<Image
src={post.imageUrl}
alt=""
fill
sizes="(max-width: 640px) 85vw, (max-width: 1024px) 48vw, 31vw"
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
)}
</div>
<div className="flex flex-1 flex-col p-6">
{post.publishedAt && (
<time dateTime={post.publishedAt} className="text-xs text-muted">
{formatDate(post.publishedAt)}
</time>
)}
<h3 className="mt-3 text-base font-bold leading-8 text-brand">{post.title}</h3>
{post.abstract && (
<p className="mt-3 line-clamp-3 flex-1 text-sm leading-7 text-brand/65">
{post.abstract}
</p>
)}
<span className="mt-5 flex items-center gap-2 text-sm font-bold text-brand">
{t.blog.readMore}
<span className="block h-px w-6 bg-brand transition-all duration-300 group-hover:w-10" />
</span>
</div>
</Link>
</li>
))}
</ul>
<div className="mt-8 flex justify-center gap-3">
<button
type="button"
onClick={() => step(-1)}
disabled={atStart}
aria-label={t.common.previous}
className="grid h-11 w-11 place-items-center border border-brand-100 text-brand transition-colors hover:border-brand disabled:opacity-35"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" className="rtl:hidden" />
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" className="hidden rtl:block" />
</svg>
</button>
<button
type="button"
onClick={() => step(1)}
disabled={atEnd}
aria-label={t.common.next}
className="grid h-11 w-11 place-items-center border border-brand-100 text-brand transition-colors hover:border-brand disabled:opacity-35"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" className="rtl:hidden" />
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" className="hidden rtl:block" />
</svg>
</button>
</div>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useState, type FormEvent } from "react";
import type { Dictionary } from "@/i18n/dictionaries";
type Field = "name" | "email" | "phone" | "subject" | "message";
type Errors = Partial<Record<Field, string>>;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function ContactForm({ t }: { t: Dictionary }) {
const f = t.contact.form;
const [errors, setErrors] = useState<Errors>({});
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const value = (key: Field) => String(data.get(key) ?? "").trim();
const next: Errors = {};
for (const key of ["name", "email", "message"] as Field[]) {
if (!value(key)) next[key] = f.required;
}
if (!next.email && !EMAIL_RE.test(value("email"))) next.email = f.invalidEmail;
setErrors(next);
if (Object.keys(next).length > 0) return;
// No mail backend is wired up yet — swap this for a POST to the send
// endpoint (or a server action) once the mail service is chosen.
setStatus("sending");
window.setTimeout(() => setStatus("sent"), 600);
event.currentTarget.reset();
}
const fieldClass = (key: Field) =>
`w-full border bg-white px-4 py-3 text-sm text-brand outline-none transition-colors placeholder:text-muted focus:border-brand ${
errors[key] ? "border-accent" : "border-brand-100"
}`;
return (
<form onSubmit={handleSubmit} noValidate className="space-y-5">
<div className="grid gap-5 sm:grid-cols-2">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-brand">
{f.name} <span className="text-accent">*</span>
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? "name-error" : undefined}
className={fieldClass("name")}
/>
{errors.name && (
<p id="name-error" className="mt-2 text-xs text-accent">
{errors.name}
</p>
)}
</div>
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-brand">
{f.email} <span className="text-accent">*</span>
</label>
<input
id="email"
name="email"
type="email"
dir="ltr"
autoComplete="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
className={`${fieldClass("email")} text-start`}
/>
{errors.email && (
<p id="email-error" className="mt-2 text-xs text-accent">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-brand">
{f.phone}
</label>
<input
id="phone"
name="phone"
type="tel"
dir="ltr"
autoComplete="tel"
className={`${fieldClass("phone")} text-start`}
/>
</div>
<div>
<label htmlFor="subject" className="mb-2 block text-sm font-medium text-brand">
{f.subject}
</label>
<input id="subject" name="subject" type="text" className={fieldClass("subject")} />
</div>
</div>
<div>
<label htmlFor="message" className="mb-2 block text-sm font-medium text-brand">
{f.message} <span className="text-accent">*</span>
</label>
<textarea
id="message"
name="message"
rows={6}
aria-invalid={Boolean(errors.message)}
aria-describedby={errors.message ? "message-error" : undefined}
className={`${fieldClass("message")} resize-y`}
/>
{errors.message && (
<p id="message-error" className="mt-2 text-xs text-accent">
{errors.message}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-4">
<button
type="submit"
disabled={status === "sending"}
className="bg-brand px-8 py-3.5 text-sm font-bold text-white transition-colors hover:bg-brand-700 disabled:opacity-60"
>
{status === "sending" ? f.sending : f.submit}
</button>
<p aria-live="polite" className="text-sm text-brand">
{status === "sent" ? f.success : ""}
</p>
</div>
</form>
);
}
+94
View File
@@ -0,0 +1,94 @@
import Image from "next/image";
import Link from "next/link";
import type { Locale } from "@/i18n/config";
import type { Dictionary } from "@/i18n/dictionaries";
import { site } from "@/lib/site";
export default function Footer({ locale, t }: { locale: Locale; t: Dictionary }) {
const links = [
{ href: `/${locale}`, label: t.nav.home },
{ href: `/${locale}/about`, label: t.nav.about },
{ href: `/${locale}/contact`, label: t.nav.contact },
];
return (
<footer className="bg-brand-900 text-white">
<div className="mx-auto grid max-w-7xl gap-10 px-4 py-16 sm:px-6 md:grid-cols-2 lg:grid-cols-4 lg:px-8">
<div className="lg:col-span-2">
<Image
src="/images/brand/logo-white.png"
alt={t.meta.company}
width={160}
height={54}
className="h-12 w-auto"
/>
<p className="mt-5 text-base font-bold">{t.meta.company}</p>
<p className="mt-2 text-sm text-white/70">{t.meta.tagline}</p>
<a
href={site.instagram.url}
target="_blank"
rel="noopener noreferrer"
className="mt-6 inline-flex items-center gap-2 border border-white/25 px-4 py-2 text-sm transition-colors hover:border-white hover:bg-white/15"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="2.5" y="2.5" width="19" height="19" rx="5" stroke="currentColor" strokeWidth="1.8" />
<circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.8" />
<circle cx="17.6" cy="6.4" r="1.2" fill="currentColor" />
</svg>
<span className="latin-ui">{site.instagram.handle}</span>
</a>
</div>
<div>
<h2 className="mb-5 text-sm font-bold text-brand-300">{t.footer.linksHeading}</h2>
<ul className="space-y-3 text-sm text-white/75">
{links.map((link) => (
<li key={link.href}>
<Link href={link.href} className="transition-colors hover:text-white">
{link.label}
</Link>
</li>
))}
</ul>
</div>
<div className="space-y-6 text-sm">
<div>
<h2 className="mb-3 text-sm font-bold text-brand-300">{t.footer.addressHeading}</h2>
<p className="leading-7 text-white/75">{site.address}</p>
</div>
<div>
<h2 className="mb-3 text-sm font-bold text-brand-300">{t.footer.phoneHeading}</h2>
<ul className="space-y-1.5">
{site.phones.map((phone, i) => (
<li key={phone}>
<a href={`tel:${phone}`} dir="ltr" className="latin-ui text-white/75 transition-colors hover:text-white">
{site.phonesDisplay[i]}
</a>
</li>
))}
</ul>
</div>
<div>
<h2 className="mb-3 text-sm font-bold text-brand-300">{t.footer.emailHeading}</h2>
<ul className="space-y-1.5">
{site.emails.map((email) => (
<li key={email}>
<a href={`mailto:${email}`} dir="ltr" className="latin-ui text-white/75 transition-colors hover:text-white">
{email}
</a>
</li>
))}
</ul>
</div>
</div>
</div>
<div className="border-t border-white/10">
<div className="mx-auto max-w-7xl px-4 py-5 text-center text-xs text-white/50 sm:px-6 lg:px-8">
{t.footer.rights}
</div>
</div>
</footer>
);
}
+297
View File
@@ -0,0 +1,297 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { locales, localeLabels, type Locale } from "@/i18n/config";
import { composeCategories } from "@/i18n/compose";
import AccountMenu from "./AccountMenu";
import { portalLinks, type Session } from "@/lib/portal";
import type { Dictionary } from "@/i18n/dictionaries";
import { site } from "@/lib/site";
export default function Header({
locale,
t,
session,
}: {
locale: Locale;
t: Dictionary;
session: Session | null;
}) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const categories = composeCategories(t);
// Every route is generated for every locale, so switching language is just a
// swap of the first path segment — no per-locale slug lookup needed.
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "");
const links = [
{ href: `/${locale}`, label: t.nav.home },
{ href: `/${locale}/about`, label: t.nav.about },
{ href: `/${locale}/contact`, label: t.nav.contact },
];
return (
<header className="sticky top-0 z-40 border-b border-brand-100 bg-white">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
<Link href={`/${locale}`} className="flex shrink-0 items-center gap-3">
<Image
src="/images/brand/logo.png"
alt={t.meta.company}
width={140}
height={48}
priority
className="h-9 w-auto"
/>
<span className="hidden text-xs font-bold leading-tight text-brand sm:block">
{t.meta.companyShort}
<span className="latin block text-[9px] font-normal text-muted">
{t.meta.companyLatin}
</span>
</span>
</Link>
<nav className="hidden h-full items-center gap-1 md:flex" aria-label={t.nav.home}>
{links.map((link) => {
const active = pathname === link.href;
const item = (
<Link
key={link.href}
href={link.href}
aria-current={active ? "page" : undefined}
className={`relative whitespace-nowrap px-3 py-2 text-[13px] font-medium transition-colors ${
active ? "font-bold text-brand" : "text-brand/65 hover:text-brand"
}`}
>
{link.label}
{active && (
<span className="absolute inset-x-3 -bottom-px h-0.5 bg-brand" />
)}
</Link>
);
// The products menu follows the home link.
if (link.href !== `/${locale}`) return item;
return (
<div key={link.href} className="contents">
{item}
{/* Opens on hover and on keyboard focus. `focus-within` only
fires if the panel stays focusable, so it is hidden with
opacity/visibility rather than `display: none`. */}
<div className="group relative flex h-full items-center">
<Link
href={`/${locale}#products`}
className="flex items-center gap-1.5 whitespace-nowrap px-3 py-2 text-[13px] font-medium text-brand/65 transition-colors group-hover:text-brand group-focus-within:text-brand"
>
{t.nav.products}
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="transition-transform duration-200 group-hover:rotate-180 group-focus-within:rotate-180"
>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.5" />
</svg>
</Link>
<div className="invisible absolute start-0 top-full z-50 min-w-56 -translate-y-1 border border-brand-100 bg-white py-2 opacity-0 transition duration-200 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100">
{categories.map((product) => (
<Link
key={product.title}
href={`/${locale}/products/${product.slug}`}
className="block px-5 py-2.5 text-[13px] text-brand/70 transition-colors hover:bg-surface hover:text-brand"
>
{product.title}
</Link>
))}
</div>
</div>
</div>
);
})}
</nav>
{/* RTL reverses DOM order on screen, so this reads right-to-left:
language · divider · phone · divider · account.
On screen that lands as account at the far end, then phone, then
language nearest the nav. */}
<div className="hidden items-center gap-4 md:flex">
<div className="group relative flex h-full items-center">
<button
type="button"
aria-label={t.common.switchLanguage}
className="flex items-center gap-1.5 px-2 py-2 text-[13px] font-medium text-brand/65 transition-colors group-hover:text-brand group-focus-within:text-brand"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path
d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
<span className="hidden lg:inline">{localeLabels[locale]}</span>
</button>
<div className="invisible absolute end-0 top-full z-50 min-w-36 -translate-y-1 border border-brand-100 bg-white py-2 opacity-0 transition duration-200 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100">
{locales.map((code) => (
<Link
key={code}
href={`/${code}${pathWithoutLocale}`}
hrefLang={code}
aria-current={code === locale ? "true" : undefined}
className={`block px-5 py-2.5 text-[13px] transition-colors hover:bg-surface hover:text-brand ${
code === locale ? "font-bold text-brand" : "text-brand/70"
}`}
>
{localeLabels[code]}
</Link>
))}
</div>
</div>
<span aria-hidden="true" className="h-4 w-px bg-brand-100" />
{/* Icon and number are one link. dir="ltr" keeps the icon to the left
of the number and the number itself readable. */}
<a
href={`tel:${site.phones[0]}`}
dir="ltr"
aria-label={site.phonesDisplay[0]}
className="flex items-center gap-2 text-brand/75 transition-colors hover:text-brand"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M6.2 3.5h3l1.5 4-2 1.4a12.5 12.5 0 0 0 6.4 6.4l1.4-2 4 1.5v3c0 1-.8 1.8-1.8 1.7C10.6 19.9 4.1 13.4 3.5 5.3c-.1-1 .7-1.8 1.7-1.8z"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
<span className="latin-ui hidden whitespace-nowrap text-[13px] font-medium lg:inline">
{site.phonesDisplay[0]}
</span>
</a>
<span aria-hidden="true" className="h-4 w-px bg-brand-100" />
<AccountMenu session={session} t={t} />
</div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls="mobile-nav"
aria-label="menu"
className="grid h-11 w-11 place-items-center border border-brand-100 text-brand md:hidden"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
{open ? (
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" />
) : (
<path d="M3 6h18M3 12h18M3 18h18" stroke="currentColor" strokeWidth="2" />
)}
</svg>
</button>
</div>
{open && (
<nav id="mobile-nav" className="border-t border-brand-100 bg-white md:hidden">
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className={`block border-b border-brand-100 py-3 text-sm font-medium last:border-0 ${
pathname === link.href ? "font-bold text-brand" : "text-brand/70"
}`}
>
{link.label}
</Link>
))}
<div className="border-b border-brand-100 py-3">
<Link
href={`/${locale}#products`}
onClick={() => setOpen(false)}
className="block text-sm font-medium text-brand/70"
>
{t.nav.products}
</Link>
<div className="mt-1 flex flex-col border-e-2 border-brand-100 pe-3">
{categories.map((product) => (
<Link
key={product.title}
href={`/${locale}/products/${product.slug}`}
onClick={() => setOpen(false)}
className="py-2 text-sm text-brand/60"
>
{product.title}
</Link>
))}
</div>
</div>
<div className="border-b border-brand-100 py-3">
{session ? (
<>
<p className="text-sm font-bold text-brand">
{session.displayName}
{t.account.honorific ? ` ${t.account.honorific}` : ""}
</p>
<div className="mt-1 flex flex-col">
<a href={portalLinks.profile} className="py-2 text-sm text-brand/60">
{t.account.profile}
</a>
{session.isAdmin && (
<a href={portalLinks.admin} className="py-2 text-sm text-brand/60">
{t.account.manageWebsite}
</a>
)}
<a href={portalLinks.logout} className="py-2 text-sm text-brand/60">
{t.account.logout}
</a>
</div>
</>
) : (
<a href={portalLinks.login} className="block text-sm font-medium text-brand">
{t.account.login}
</a>
)}
</div>
<div className="flex gap-4 border-b border-brand-100 py-3">
{locales.map((code) => (
<Link
key={code}
href={`/${code}${pathWithoutLocale}`}
hrefLang={code}
onClick={() => setOpen(false)}
className={`text-sm ${
code === locale ? "font-bold text-brand" : "text-brand/60"
}`}
>
{localeLabels[code]}
</Link>
))}
</div>
<a
href={`tel:${site.phones[0]}`}
dir="ltr"
className="latin-ui block py-3 text-sm text-muted"
>
{site.phonesDisplay[0]}
</a>
</div>
</nav>
)}
</header>
);
}
+158
View File
@@ -0,0 +1,158 @@
import type { Dictionary } from "@/i18n/dictionaries";
import { instruments, type MarketSnapshot } from "@/lib/market";
/**
* Server component — the fetch happens during the (revalidated) render, so the
* upstream is never called from a visitor's browser. livedata.ir sends no CORS
* headers, so a client-side fetch would be blocked anyway.
*/
export default function MarketFrame({
snapshot,
t,
locale,
}: {
snapshot: MarketSnapshot;
t: Dictionary;
locale: string;
}) {
const labels = t.market.labels as Record<string, string | undefined>;
const asOf = new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(snapshot.fetchedAt));
/**
* Upstream groups some figures ("29,136,700") and not others ("199000").
* Group the ungrouped ones so the frame is internally consistent — purely
* presentational, the digits themselves are never altered.
*/
const group = (value: string) =>
/^\d+$/.test(value) && value.length > 4
? value.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
: value;
/** Upstream publishes "Aug 31, 2026"; show it in the reader's own calendar. */
const formatReopen = (value: string) => {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format(parsed);
};
return (
<div className="border border-brand-100 bg-white">
<div className="grid divide-y divide-brand-100 lg:grid-cols-[1.1fr_1fr_0.8fr] lg:divide-x lg:divide-y-0 rtl:lg:divide-x-reverse">
{/* Figures */}
<ul className="divide-y divide-brand-100">
{snapshot.quotes.map((quote) => (
<li key={quote.id} className="flex items-baseline justify-between gap-4 px-6 py-4">
<span className="text-sm text-brand/70">
{labels[quote.id] ?? instruments[quote.id].fa}
</span>
<span className="flex items-baseline gap-2">
<span dir="ltr" className="latin-ui text-base font-bold text-brand">
{group(quote.price)}
</span>
{quote.direction !== "flat" && (
<span
dir="ltr"
className={`latin-ui text-xs ${
quote.direction === "up" ? "text-up" : "text-down"
}`}
>
{quote.direction === "up" ? "▲" : "▼"} {quote.changePercent}
</span>
)}
</span>
</li>
))}
</ul>
{/* Gold by purity */}
<div className="px-6 py-4">
<h3 className="text-xs font-bold text-brand-500">{t.market.gold.heading}</h3>
<table className="mt-3 w-full text-sm">
<thead>
{/* Headers and values both align to the page's start edge. The
numbers carry dir="ltr" on an inner span, not on the cell —
putting it on the cell flipped what `text-end` meant and left
each header sitting on the opposite side of its own column. */}
<tr className="text-[11px] text-muted">
<th scope="col" className="pb-2 text-start font-normal" />
<th scope="col" className="pb-2 text-start font-normal">
{t.market.gold.toman}
</th>
<th scope="col" className="pb-2 text-start font-normal">
{t.market.gold.usd}
</th>
</tr>
</thead>
<tbody className="divide-y divide-brand-100">
{snapshot.gold.map((row) => (
<tr key={row.key}>
<th scope="row" className="py-2 text-start font-normal text-brand/70">
{t.market.gold.rows[row.key]}
</th>
<td className="latin-ui py-2 text-start font-bold text-brand">
{row.toman ? <span dir="ltr">{group(row.toman)}</span> : "—"}
</td>
<td className="latin-ui py-2 text-start text-brand/60">
<span dir="ltr">{row.usd}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Exchange sessions */}
<div className="px-6 py-4">
<h3 className="text-xs font-bold text-brand-500">{t.market.sessions.heading}</h3>
<ul className="mt-3 divide-y divide-brand-100">
{snapshot.sessions.map((session) => (
<li
key={session.key}
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
>
<span className="text-sm text-brand/70">
{t.market.sessions.names[session.key]}
</span>
<span className="flex flex-col items-end gap-1">
{/* Square chip, not a pill — nothing else on the site is rounded. */}
<span
className={`px-2 py-0.5 text-[11px] font-bold ${
session.open ? "bg-up/12 text-up" : "bg-muted/15 text-muted"
}`}
>
{session.open ? t.market.sessions.open : t.market.sessions.closed}
</span>
{session.reopensOn && (
<span className="text-[11px] text-muted">
{t.market.sessions.reopens}{" "}
<span className="latin-ui">{formatReopen(session.reopensOn)}</span>
</span>
)}
</span>
</li>
))}
</ul>
</div>
</div>
{/* Attribution is not decoration — these figures are compiled by someone else. */}
<p className="border-t border-brand-100 px-6 py-3 text-xs text-muted">
{t.market.asOf} <span className="latin-ui">{asOf}</span>
{" · "}
{t.market.source}{" "}
<a
href={snapshot.source.url}
target="_blank"
rel="noopener noreferrer nofollow"
className="latin-ui text-brand/70 underline-offset-2 hover:text-brand hover:underline"
>
{snapshot.source.name}
</a>
</p>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import Image from "next/image";
import { balanceLines } from "@/lib/text";
/** Compact page banner used by the inner pages, in place of the home slider. */
export default function PageHero({
title,
latin,
image,
}: {
title: string;
latin: string;
image: string;
}) {
return (
<section className="relative h-56 w-full overflow-hidden bg-brand-900 sm:h-72">
<Image src={image} alt="" fill priority sizes="100vw" className="object-cover" />
<div className="absolute inset-0 bg-brand-900/80" />
<div className="relative mx-auto flex h-full max-w-7xl items-center px-4 sm:px-6 lg:px-8">
{/* w-full is required: `container-type: inline-size` stops an element
sizing to its own content, so as a bare flex item it collapses. */}
<div className="@container relative w-full text-start">
{/* Empty in English, where the gloss would just repeat the heading. */}
{latin && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 select-none font-display text-[clamp(1.75rem,12cqw,3.5rem)] uppercase leading-[0.95] tracking-[0.05em] text-white/10"
>
{balanceLines(latin).map((line) => (
<span key={line} className="block">
{line}
</span>
))}
</span>
)}
<h1 className="relative text-3xl font-bold text-white sm:text-4xl">{title}</h1>
<span className="relative mt-5 block h-1 w-16 bg-brand-300" />
</div>
</div>
</section>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { ReactNode } from "react";
export type ProductIconName =
| "oil"
| "mineral"
| "food"
| "chemical"
| "livestock"
| "appliance"
| "construction";
/**
* Minimal line icons for the product categories. Single weight, no fills, and
* they draw in `currentColor` so a card can recolour them on hover.
*/
const shapes: Record<ProductIconName, ReactNode> = {
// Droplet — oil derivatives
oil: <path d="M12 3.5c3.2 3.7 5 6.5 5 8.7a5 5 0 0 1-10 0c0-2.2 1.8-5 5-8.7Z" />,
// Mountain range — mineral products
mineral: (
<>
<path d="M2.5 19.5h19L14.6 7.5l-3.1 5.2-2.2-3.1z" />
<path d="M6.2 14.2h3.4" />
</>
),
// Wheat stalk — food materials and products
food: (
<>
<path d="M12 21V7" />
<path d="M12 11.5 8.4 8.8M12 11.5l3.6-2.7" />
<path d="M12 16.2l-3.6-2.7M12 16.2l3.6-2.7" />
</>
),
// Conical flask — chemical products
chemical: (
<>
<path d="M9.5 3h5" />
<path d="M10.6 3v6.3L5.8 18a2 2 0 0 0 1.8 3h8.8a2 2 0 0 0 1.8-3l-4.8-8.7V3" />
</>
),
// Feed sack — livestock and poultry inputs
livestock: (
<>
<path d="M9.2 3h5.6l-.8 3.2H10z" />
<path d="M10 6.2h4c2 1 3.3 3 3.3 5.2v5.3A4.3 4.3 0 0 1 13 21h-2a4.3 4.3 0 0 1-4.3-4.3v-5.3c0-2.2 1.3-4.2 3.3-5.2Z" />
</>
),
// Washing machine — home appliances
appliance: (
<>
<rect x="4.5" y="3" width="15" height="18" rx="2" />
<circle cx="12" cy="14" r="3.8" />
<path d="M8 6.6h.01M11 6.6h.01" />
</>
),
// Brick wall — building materials and equipment
construction: (
<>
<path d="M3.5 5.5h17v13h-17z" />
<path d="M3.5 12h17" />
<path d="M9.5 5.5V12M14.5 12v6.5" />
</>
),
};
export default function ProductIcon({
name,
className = "h-11 w-11 sm:h-12 sm:w-12",
}: {
name: ProductIconName;
className?: string;
}) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
{shapes[name]}
</svg>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
type RevealTag = "div" | "li" | "article" | "section";
/**
* Fades and lifts its content into place the first time it scrolls into view.
* Pass an increasing `delay` across a list to stagger the sequence.
*
* Renders as whatever element the slot needs (`li` inside a list, `article` in a
* grid) so it never introduces a wrapper that would break list semantics or grid
* placement. Honours `prefers-reduced-motion` by showing content immediately.
*
* The starting state is hidden, so every instance carries `data-reveal` and the
* layout ships a `<noscript>` rule that forces those elements visible. Without
* it, a visitor with JS disabled would get a page of blank sections.
*/
export default function Reveal({
children,
as = "div",
delay = 0,
className = "",
id,
}: {
children: ReactNode;
as?: RevealTag;
delay?: number;
className?: string;
id?: string;
}) {
const ref = useRef<HTMLElement>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
// Show immediately when the animation should not or cannot run.
if (
!("IntersectionObserver" in window) ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
setShown(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
setShown(true);
observer.disconnect();
},
{ threshold: 0.12, rootMargin: "0px 0px -8% 0px" }
);
observer.observe(element);
return () => observer.disconnect();
}, []);
const Tag = as;
return (
<Tag
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={ref as any}
id={id}
data-reveal=""
style={{ transitionDelay: shown ? `${delay}ms` : "0ms" }}
className={`transition-[opacity,transform] duration-700 ease-out ${
shown ? "translate-y-0 opacity-100" : "translate-y-8 opacity-0"
} ${className}`}
>
{children}
</Tag>
);
}
+91
View File
@@ -0,0 +1,91 @@
import type { ReactNode } from "react";
import { balanceLines } from "@/lib/text";
/**
* Shared section heading.
*
* The Latin line sits *behind* the whole lockup — title, subtitle and rule — as
* a faint backdrop, centred on the group rather than on the title alone. It is
* decorative repetition of the heading, so it is `aria-hidden`.
*
* Note the backdrop carries `font-display` rather than `.latin-title`: any
* `direction: ltr` on a positioned element makes `inset-inline` and
* `text-align: start` resolve against that element instead of the page, which
* left-aligns it inside an RTL layout.
*/
export function SectionHeading({
title,
latin,
subtitle,
align = "center",
tone = "dark",
}: {
title: string;
latin?: string;
subtitle?: string;
align?: "center" | "start";
/** `dark` = navy text on a light section, `light` = white text on a navy one. */
tone?: "dark" | "light";
}) {
const light = tone === "light";
const centred = align === "center";
return (
/* `@container` + cqw units size the backdrop against this heading's own
column, not the viewport — otherwise it overflows a narrow column. */
<div className={`@container relative ${centred ? "text-center" : "text-start"}`}>
{latin && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 select-none font-display text-[clamp(1.25rem,8cqw,3rem)] uppercase leading-[0.95] tracking-[0.05em] ${
light ? "text-white/10" : "text-brand/10"
}`}
>
{balanceLines(latin).map((line) => (
<span key={line} className="block">
{line}
</span>
))}
</span>
)}
<h2
className={`relative text-2xl font-bold sm:text-3xl ${
light ? "text-white" : "text-brand"
}`}
>
{title}
</h2>
{subtitle && (
<p
className={`relative mt-2 text-sm leading-6 ${
centred ? "mx-auto max-w-2xl" : "max-w-2xl"
} ${light ? "text-white/70" : "text-brand/70"}`}
>
{subtitle}
</p>
)}
<span
className={`relative mt-3 block h-1 w-16 ${light ? "bg-brand-300" : "bg-brand"} ${
centred ? "mx-auto" : ""
}`}
/>
</div>
);
}
export function Section({
children,
className = "",
}: {
children: ReactNode;
className?: string;
}) {
return (
<section className={`px-4 py-16 sm:px-6 sm:py-20 lg:px-8 ${className}`}>
<div className="mx-auto max-w-7xl">{children}</div>
</section>
);
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import type { Dictionary } from "@/i18n/dictionaries";
import type { ComposedSlide } from "@/i18n/compose";
const AUTOPLAY_MS = 6000;
/** Full-bleed hero slider that sits directly under the header. */
export default function Slider({ slides, t }: { slides: ComposedSlide[]; t: Dictionary }) {
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const count = slides.length;
const go = useCallback((next: number) => setIndex((next + count) % count), [count]);
// Respect the user's motion preference: no autoplay when reduced motion is on.
const reduced = useRef(false);
useEffect(() => {
reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}, []);
useEffect(() => {
if (paused || reduced.current) return;
const id = window.setInterval(() => setIndex((i) => (i + 1) % count), AUTOPLAY_MS);
return () => window.clearInterval(id);
}, [paused, count]);
return (
<section
aria-roledescription="carousel"
aria-label={t.meta.company}
className="relative h-[70vh] min-h-[420px] w-full overflow-hidden bg-brand-900 sm:h-[78vh]"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={() => setPaused(false)}
>
{slides.map((slide, i) => {
// The Latin line is set one word per line as an oversized backdrop.
const words = slide.subtitle.split(" ").filter(Boolean);
return (
<div
key={slide.image}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} / ${count}`}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ${
i === index ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<Image
src={slide.image}
alt=""
fill
priority={i === 0}
sizes="100vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-linear-to-t from-brand-900/95 from-0% via-brand-900/55 via-25% to-transparent to-60%" />
<div className="relative mx-auto flex h-full max-w-7xl items-end px-4 pb-28 sm:px-6 sm:pb-32 lg:px-8">
<div className="relative max-w-2xl">
{/* Content animates in only while its slide is the active one, so
the sequence replays on every change rather than once on load. */}
{/* Oversized, faint Latin title sitting behind the Persian one.
Decorative — the same words are already in the heading lockup,
so it is hidden from assistive tech. */}
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-0 top-1/2 select-none text-start transition-all duration-1000 ease-out ${
i === index ? "-translate-y-1/2 opacity-100" : "-translate-y-[40%] opacity-0"
}`}
>
{words.map((word) => (
<span
key={word}
className="block font-display text-[clamp(2.75rem,7.5vw,6rem)] uppercase leading-[0.85] tracking-[0.08em] text-white/10"
>
{word}
</span>
))}
</span>
<h2
className={`relative text-3xl font-bold leading-[1.35] text-white transition-all duration-700 ease-out sm:text-5xl sm:leading-[1.3] ${
i === index ? "translate-y-0 opacity-100 delay-150" : "translate-y-5 opacity-0"
}`}
>
{slide.title}
</h2>
<p
className={`relative mt-5 max-w-xl text-sm leading-8 text-white/75 transition-all duration-700 ease-out sm:text-base sm:leading-9 ${
i === index ? "translate-y-0 opacity-100 delay-300" : "translate-y-5 opacity-0"
}`}
>
{slide.description}
</p>
<span
className={`relative mt-7 block h-1 bg-brand-300 transition-all duration-700 ease-out ${
i === index ? "w-24 opacity-100 delay-500" : "w-0 opacity-0"
}`}
/>
</div>
</div>
</div>
);
})}
<div className="absolute inset-x-0 bottom-0 z-10">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 pb-8 sm:px-6 lg:px-8">
<div className="flex gap-2">
{slides.map((slide, i) => (
<button
key={slide.image}
type="button"
onClick={() => go(i)}
aria-label={`${t.common.goToSlide} ${i + 1}`}
aria-current={i === index}
className={`h-1.5 transition-all ${
i === index ? "w-10 bg-white" : "w-5 bg-white/40 hover:bg-white/70"
}`}
/>
))}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => go(index - 1)}
aria-label={t.common.previous}
className="grid h-11 w-11 place-items-center border border-white/35 text-white transition-colors hover:border-white hover:bg-white/20"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M15 6l-6 6 6 6"
stroke="currentColor"
strokeWidth="2"
className="rtl:hidden"
/>
<path
d="M9 6l6 6-6 6"
stroke="currentColor"
strokeWidth="2"
className="hidden rtl:block"
/>
</svg>
</button>
<button
type="button"
onClick={() => go(index + 1)}
aria-label={t.common.next}
className="grid h-11 w-11 place-items-center border border-white/35 text-white transition-colors hover:border-white hover:bg-white/20"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M9 6l6 6-6 6"
stroke="currentColor"
strokeWidth="2"
className="rtl:hidden"
/>
<path
d="M15 6l-6 6 6 6"
stroke="currentColor"
strokeWidth="2"
className="hidden rtl:block"
/>
</svg>
</button>
</div>
</div>
</div>
</section>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { ProductIconName } from "@/components/ProductIcon";
/**
* Structure, not copy.
*
* Everything here is identical in every language: ids, slugs, image paths and
* ordering. It stays in TypeScript so slugs keep their literal types (see
* `ProductIcon`) and so an image path exists exactly once rather than being
* copied into each locale file. Translators never see this file.
*
* The ids below are the keys used in `src/i18n/locales/<locale>.json`.
*/
export const slides = [
{ id: "company", image: "/images/sample/deck-containers.jpg" },
{ id: "trade", image: "/images/sample/aerial-vessel.jpg" },
{ id: "partner", image: "/images/sample/container-canyon.jpg" },
] as const;
export const services = [
{ id: "transport", image: "/images/sample/port-vessel.jpg" },
{ id: "technical", image: "/images/sample/container-canyon.jpg" },
{ id: "clearance", image: "/images/sample/container-stack.jpg" },
{ id: "trade", image: "/images/sample/aerial-vessel.jpg" },
] as const;
export const productsBackground = "/images/sample/deck-containers.jpg";
export const productCategories = [
{ slug: "oil", image: "/images/slider/slider3.jpg" },
{ slug: "mineral", image: "/images/sample/container-canyon.jpg" },
{ slug: "food", image: "/images/slider/slider5.jpg" },
{ slug: "chemical", image: "/images/slider/slider2.jpg" },
{ slug: "livestock", image: "/images/slider/slider6.jpg" },
{ slug: "construction", image: "/images/slider/slider4.jpg" },
] as const;
export const pageImages = {
about: "/images/slider/slider2.jpg",
contact: "/images/slider/slider4.jpg",
intro: "/images/slider/slider5.jpg",
aboutPortrait: "/images/slider/slider1.jpg",
} as const;
export type SlideId = (typeof slides)[number]["id"];
export type ServiceId = (typeof services)[number]["id"];
export type ProductSlug = (typeof productCategories)[number]["slug"];
// Compile-time proof that every category still has an icon behind it.
const _iconCheck: Record<ProductSlug, ProductIconName> = {
oil: "oil",
mineral: "mineral",
food: "food",
chemical: "chemical",
livestock: "livestock",
construction: "construction",
};
void _iconCheck;
+33
View File
@@ -0,0 +1,33 @@
import { productCategories, services, slides } from "@/content";
import type { Dictionary } from "./types";
/**
* Joins structure (`src/content`) to copy (`src/i18n/locales/*.json`) on the id.
*
* Keeping these apart is what stops an image path being copied into three locale
* files and drifting; these helpers put them back together at render time.
*/
export function composeSlides(t: Dictionary) {
return slides.map((slide) => ({ ...slide, ...t.slides[slide.id] }));
}
export function composeServices(t: Dictionary) {
return services.map((service) => ({ ...service, ...t.services.items[service.id] }));
}
export function composeCategories(t: Dictionary) {
return productCategories.map((category) => ({
...category,
...t.products.categories[category.slug],
}));
}
export function composeCategory(t: Dictionary, slug: string) {
const category = productCategories.find((item) => item.slug === slug);
if (!category) return null;
return { ...category, ...t.products.categories[category.slug] };
}
export type ComposedSlide = ReturnType<typeof composeSlides>[number];
export type ComposedService = ReturnType<typeof composeServices>[number];
export type ComposedCategory = ReturnType<typeof composeCategories>[number];
+28
View File
@@ -0,0 +1,28 @@
/**
* Locale configuration.
*
* Static-page copy is translated into all three. The blog is not — posts are
* single-language and live in `src/lib/posts.ts`; see README, "Multi-language".
*/
export const locales = ["fa", "en", "ar"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "fa";
export const localeDirection: Record<Locale, "rtl" | "ltr"> = {
fa: "rtl",
ar: "rtl",
en: "ltr",
};
/** Shown in the language switcher, each in its own language. */
export const localeLabels: Record<Locale, string> = {
fa: "فارسی",
en: "English",
ar: "العربية",
};
export function isLocale(value: string): value is Locale {
return (locales as readonly string[]).includes(value);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Locale } from "./config";
import type { Dictionary } from "./types";
const loaders: Record<Locale, () => Promise<{ default: Dictionary }>> = {
fa: () => import("./locales/fa"),
en: () => import("./locales/en"),
ar: () => import("./locales/ar"),
};
export async function getDictionary(locale: Locale): Promise<Dictionary> {
const mod = await loaders[locale]();
return mod.default;
}
export type { Dictionary };
+335
View File
@@ -0,0 +1,335 @@
{
"meta": {
"company": "مجموعة آريو مجد آسيا التجارية الدولية",
"companyShort": "مجموعة آريو مجد آسيا التجارية",
"companyLatin": "ARIO MAJD ASIA",
"tagline": "شريككم الدائم في التجارة الدولية",
"description": "مجموعة آريو مجد آسيا التجارية الدولية؛ الخدمات الفنية والهندسية، التجارة، النقل الدولي، التأمين والتخليص الجمركي."
},
"nav": {
"home": "الصفحة الرئيسية",
"products": "المنتجات",
"about": "من نحن",
"contact": "اتصل بنا"
},
"slides": {
"company": {
"title": "مجموعة آريو مجد آسيا التجارية",
"subtitle": "ARIO MAJD ASIA",
"description": "تقديم الخدمات الفنية والهندسية والتجارية واللوجستية على المستوى الدولي، استناداً إلى خبرة إدارة المجموعة والتعاون مع شركات محلية ودولية معتمدة."
},
"trade": {
"title": "استيراد وتصدير البضائع",
"subtitle": "IMPORT AND EXPORT",
"description": "استيراد وتصدير وإعادة تصدير الحبوب والمواد الغذائية ومدخلات الثروة الحيوانية والمشتقات النفطية والمنتجات الكيميائية والمعدنية."
},
"partner": {
"title": "شريككم الدائم في التجارة الدولية",
"subtitle": "YOUR TRADE PARTNER",
"description": "من التأمين والتوريد إلى النقل الدولي والتخليص من جميع جمارك البلاد؛ إلى جانبكم منذ عام ٢٠١٩."
}
},
"services": {
"heading": "خدماتنا",
"headingLatin": "OUR SERVICES",
"subtitle": "مجالات الخدمة الأربعة الأساسية لمجموعة آريو مجد آسيا التجارية الدولية",
"items": {
"transport": {
"title": "النقل وتأمين البضائع التجارية",
"latin": "TRANSPORT",
"description": "النقل الدولي وتأمين البضائع التجارية حتى الوجهة النهائية."
},
"technical": {
"title": "الخدمات الفنية والهندسية",
"latin": "TECHNICAL SERVICES",
"description": "التخطيط العمراني والعمارة والهندسة المدنية والمساحة والتمديدات الكهربائية والميكانيكية."
},
"clearance": {
"title": "التخليص الجمركي",
"latin": "DESIRED PRODUCTS",
"description": "تخليص البضائع التجارية المسموح بها من جميع جمارك البلاد."
},
"trade": {
"title": "استيراد وتصدير البضائع",
"latin": "IMPORT AND EXPORT",
"description": "الاستيراد والتصدير وإعادة التصدير في مجالات تخصص المجموعة."
}
}
},
"products": {
"heading": "المنتجات",
"headingLatin": "PRODUCTS",
"lead": "المجالات الرئيسية للنشاط التجاري للمجموعة",
"goodsHeading": "الأصناف المتوفرة",
"goodsLatin": "AVAILABLE GOODS",
"servicesNote": "تتولى المجموعة أيضاً لجميع أصناف هذه الفئة خدمات النقل الدولي وتأمين البضائع والتخليص من جمارك البلاد.",
"otherHeading": "الفئات الأخرى",
"otherLatin": "OTHER CATEGORIES",
"inquiryHeading": "اطلب عرض سعر لهذه الفئة",
"inquiryBody": "للحصول على الأسعار وشروط التوريد ومواعيد التسليم، تواصلوا مع فريقنا التجاري.",
"inquiryButton": "اتصل بنا",
"categories": {
"oil": {
"title": "المشتقات النفطية",
"latin": "OIL DERIVATIVES",
"intro": "تُعد المشتقات النفطية من أهم مجالات النشاط التجاري لمجموعة آريو مجد آسيا. يتم توريد هذه المنتجات من المصافي والوحدات الإنتاجية المحلية وتصديرها مع تنسيق كامل للنقل والتأمين والتخليص.",
"goods": [
"القير",
"زيت الأساس",
"الكبريت",
"البارافين",
"الشمع",
"النفتا",
"زيت الغاز",
"القطران"
]
},
"mineral": {
"title": "المنتجات المعدنية",
"latin": "MINERAL PRODUCTS",
"intro": "تُعد إيران من أغنى دول العالم من حيث الاحتياطيات المعدنية. تتولى المجموعة، اعتماداً على علاقتها بالمناجم ووحدات المعالجة المحلية، توريد وتصدير المنتجات المعدنية بمختلف الحمولات.",
"goods": [
"خام الحديد",
"المركزات",
"الكريات",
"حجر الجبس",
"الفلسبار",
"الباريت",
"البنتونيت",
"السيليكا"
]
},
"food": {
"title": "المواد والمنتجات الغذائية",
"latin": "FOOD PRODUCTS",
"intro": "يُعد استيراد وتصدير وإعادة تصدير الحبوب والمواد الغذائية من أكبر أنشطة المجموعة حجماً. يتم توريد هذه الأصناف من الأسواق الدولية وإيصالها إلى السوق المحلية مع مراعاة المعايير الصحية والحجر الزراعي.",
"goods": [
"الحبوب",
"القمح",
"الأرز",
"الذرة",
"السكر",
"زيت الطعام",
"البقوليات",
"المكسرات والفواكه المجففة"
]
},
"chemical": {
"title": "المنتجات الكيميائية",
"latin": "CHEMICAL PRODUCTS",
"intro": "تُعد المنتجات الكيميائية والبوليمرية من أهم مخرجات صناعة البتروكيماويات في البلاد. تتولى المجموعة توريد هذه الأصناف للوحدات الصناعية المحلية وتصديرها إلى أسواق المنطقة.",
"goods": [
"البولي إيثيلين",
"البولي بروبيلين",
"البي في سي",
"الصودا الكاوية",
"اليوريا",
"الميثانول",
"الأسمدة الكيميائية",
"الراتنج"
]
},
"livestock": {
"title": "مدخلات الثروة الحيوانية والدواجن",
"latin": "LIVESTOCK INPUTS",
"intro": "يُعد توريد مدخلات الثروة الحيوانية والدواجن من الأسواق الدولية وتخليصها من جمارك البلاد من الخدمات المتخصصة للمجموعة لمزارع الماشية والدواجن ومصانع الأعلاف.",
"goods": [
"ذرة العلف",
"كسبة فول الصويا",
"الشعير",
"النخالة",
"مسحوق السمك",
"البرسيم",
"مكملات الأعلاف"
]
},
"construction": {
"title": "مواد ومعدات البناء",
"latin": "BUILDING MATERIALS",
"intro": "إلى جانب الخدمات الفنية والهندسية، تتولى المجموعة توريد المواد والمعدات والآليات اللازمة لمشاريع البنية التحتية والبناء.",
"goods": [
"الإسمنت",
"حديد التسليح",
"الكمرات الحديدية",
"البلاط والسيراميك",
"الخلاطات الصحية",
"العوازل الإنشائية",
"معدات التمديدات"
]
}
}
},
"home": {
"introHeading": "عن آريو مجد آسيا",
"introLatin": "ABOUT US",
"introBody": "بدأت مجموعة آريو مجد آسيا التجارية الدولية نشاطها الرسمي عام ٢٠١٩، مستندة إلى التعاون مع شركات محلية ودولية معتمدة وإلى خبرة إدارة المجموعة في تقديم الخدمات الفنية والهندسية والتوريد والتسويق والتجارة الدولية والتأمين والنقل الدولي وخدمات تخليص البضائع التجارية المسموح بها.",
"introCta": "اعرف المزيد",
"partnersHeading": "شركاؤنا",
"partnersLatin": "OUR PARTNERS",
"partnersSubtitle": "الهيئات والمؤسسات التي تتعامل معها المجموعة",
"ctaHeading": "نحن جاهزون للعمل معكم",
"ctaBody": "للحصول على الاستشارة في مجالات الاستيراد والتصدير والتخليص الجمركي والخدمات الفنية والهندسية، تواصلوا معنا.",
"ctaButton": "اتصل بنا"
},
"about": {
"heading": "من نحن",
"headingLatin": "ABOUT US",
"paragraphs": [
"بدأت مجموعة آريو مجد آسيا التجارية الدولية نشاطها الرسمي عام ٢٠١٩، مستندة إلى التعاون مع شركات محلية ودولية معتمدة وإلى خبرة إدارة المجموعة في تقديم الخدمات الفنية والهندسية والتوريد والتسويق والتجارة الدولية والتأمين والنقل الدولي وخدمات تخليص البضائع التجارية المسموح بها.",
"تتركز الخدمات الفنية والهندسية للمجموعة في مجالات التخطيط العمراني والعمارة والهندسة المدنية والمساحة والنقل والمرور والتمديدات الكهربائية والميكانيكية وتوريد البضائع والمعدات ومواد البناء.",
"أما الأنشطة التجارية للمجموعة فتتركز في استيراد وتصدير وإعادة تصدير الحبوب والمواد الغذائية ومدخلات الثروة الحيوانية والمشتقات النفطية والمنتجات الكيميائية والمعدنية والأجهزة المنزلية، إضافة إلى التأمين والنقل الدولي وتخليصها من جميع جمارك البلاد.",
"يشرفنا أن نكون شريككم الدائم في التجارة الدولية، بالتعاون مع الخبراء والمستشارين في الوحدات القانونية والتجارية والمالية وإدارة الصناعات والمشاريع والهندسة والتأمين والنقل والتخليص الجمركي في المجموعة."
],
"vision": {
"heading": "الرؤية",
"headingLatin": "PERSPECTIVE",
"subtitle": "المكانة التي نرسمها للمجموعة خلال العقد القادم",
"paragraphs": [
"من خلال تخطيط متخصصيها والاستفادة من الخبرات والفرص الحديثة في تقديم الخدمات الفنية والهندسية والتجارية واللوجستية بما يواكب تقنيات العالم المعاصر، ستُعرف مجموعة آريو مجد آسيا التجارية الدولية خلال العقد القادم كواحدة من الشركات الخاصة العشر الرائدة والموثوقة في البلاد.",
"نأمل، عبر تسهيل التعاون التجاري الدولي من خلال تقديم خدمات متميزة ومتنوعة وكسب ثقة ورضا العملاء المحليين والدوليين، أن نخطو خطوة ولو صغيرة نحو التقدم الاقتصادي وازدهار وطننا إيران."
]
},
"mission": {
"heading": "الرسالة والأهداف",
"headingLatin": "MISSION AND GOALS",
"subtitle": "المبادئ التي توجّه العمل اليومي للمجموعة",
"items": [
"وضع رضا العملاء على رأس أولويات عملنا",
"الاستعانة بكوادر ذات خبرة وتعليم، وتحديث معارفهم ورفع مهاراتهم ومستوى صحتهم",
"الاستفادة من إدارة المشاريع وإدارة التكاليف في تقديم الخدمات",
"تقليل الوقت والتكلفة والمخاطر في تقديم الخدمات الفنية والهندسية والتجارية والنقلية للعملاء",
"تقديم أفضل الخدمات الفنية والهندسية والتجارية والنقلية وأعلاها جودة للعملاء",
"الوصول إلى الأساليب والتقنيات العالمية الحديثة ونقل معارفها الفنية إلى البلاد، مواكبةً لوتيرة التقدم العالمي",
"تطوير التكنولوجيا والمعرفة الهندسية المرتبطة بنشاط الشركة في القطاعين الخدمي والتجاري",
"التحول إلى مؤسسة متميزة ورائدة على المستوى الدولي",
"توسيع نطاق السوق عبر التسويق والتعاون مع شركات محلية ودولية معتمدة",
"تقديم الاستشارة لتيسير الوصول إلى المعلومات والظروف والفرص الاقتصادية في السوق المستهدفة",
"التعاون مع الشركات الاستشارية والقانونية في دول التصدير المستهدفة للتعرف على الفرص الجديدة وتهيئة ظروف تجارية مناسبة للعملاء وتحقيق رضاهم",
"تطوير موارد تقديم وتوريد الخدمات والسلع التجارية بما يتناسب مع نمو السوق",
"الالتزام بحماية البيئة والإيمان بالتنمية المستدامة",
"الاستفادة من الإمكانات المحلية لصالح الشركات الدولية"
]
},
"values": {
"heading": "قيم المؤسسة",
"headingLatin": "VALUES OF THE ORGANIZATION",
"subtitle": "ما نلتزم به في كل تعاون",
"items": [
"الالتزام بالقيم الأخلاقية والإسلامية",
"شعور الكوادر بالمسؤولية تجاه العملاء",
"حفظ وضمان أمن معلومات العملاء",
"حماية مصالح المساهمين وكوادر الشركة",
"احترام القانون والعدالة",
"الصدق والأمانة",
"تكريم المراجعين",
"الإبداع والابتكار",
"الجدارة والاستحقاق",
"الثقة المتبادلة"
]
}
},
"contact": {
"heading": "اتصل بنا",
"headingLatin": "CONTACT US",
"lead": "التواصل مع آريو مجد آسيا",
"leadLatin": "CONTACT WITH ARIO MAJD ASIA",
"leadSubtitle": "أرسلوا رسالتكم وسيرد عليكم خبراؤنا في أقرب وقت ممكن.",
"addressHeading": "عنواننا",
"addressLatin": "ADDRESS",
"emailHeading": "راسلونا",
"emailLatin": "EMAIL",
"phoneHeading": "معلومات الاتصال",
"phoneLatin": "CONTACT US",
"socialHeading": "تابعونا",
"socialLatin": "STAY WITH US",
"viewOnMap": "عرض على الخريطة",
"form": {
"name": "الاسم الكامل",
"email": "البريد الإلكتروني",
"phone": "رقم الهاتف",
"subject": "الموضوع",
"message": "رسالتكم",
"submit": "إرسال الرسالة",
"sending": "جارٍ الإرسال…",
"success": "تم استلام رسالتكم بنجاح. سنتواصل معكم قريباً.",
"required": "هذا الحقل مطلوب.",
"invalidEmail": "يرجى إدخال بريد إلكتروني صحيح."
}
},
"blog": {
"heading": "الأخبار والمقالات",
"headingLatin": "BLOG",
"subtitle": "مقالات خبراء المجموعة في مجالات التجارة والنقل والتخليص الجمركي",
"readMore": "اقرأ المزيد",
"publishedOn": "تاريخ النشر",
"viewAll": "جميع المقالات",
"backToList": "العودة إلى قائمة المقالات",
"empty": "لم يتم نشر أي مقال بعد."
},
"market": {
"heading": "السوق اليوم",
"headingLatin": "MARKET TODAY",
"subtitle": "أسعار العملات والذهب والنفط، مع حالة أهم بورصات العالم",
"asOf": "آخر تحديث",
"source": "المصدر:",
"labels": {
"300106": "أونصة الذهب",
"400106": "نفط برنت",
"200103": "دولار تيثر",
"200101": "الدولار (الصرافة)",
"200201": "اليورو (الصرافة)"
},
"gold": {
"heading": "الذهب حسب العيار",
"toman": "السوق الإيرانية (تومان)",
"usd": "السوق العالمية (دولار)",
"rows": {
"g24": "غرام الذهب عيار ٢٤",
"g22": "غرام الذهب عيار ٢٢",
"g18": "غرام الذهب عيار ١٨",
"ounce": "أونصة الذهب العالمية"
}
},
"sessions": {
"heading": "البورصات العالمية",
"open": "مفتوحة",
"closed": "مغلقة",
"reopens": "تُفتح:",
"names": {
"newYork": "نيويورك",
"london": "لندن",
"tokyo": "طوكيو"
}
}
},
"partners": {
"isipo": "منظمة الصناعات الصغيرة والمدن الصناعية في إيران",
"mimt": "وزارة الصناعة والمناجم والتجارة",
"iccima": "غرفة التجارة والصناعة والمناجم والزراعة في إيران",
"qomccima": "غرفة التجارة والصناعة والمناجم والزراعة في قم",
"irica": "مصلحة الجمارك في الجمهورية الإسلامية الإيرانية",
"iranfair": "شركة معارض إيران الدولية"
},
"footer": {
"linksHeading": "الأقسام",
"addressHeading": "العنوان",
"phoneHeading": "رقم الهاتف",
"emailHeading": "البريد الإلكتروني",
"rights": "جميع حقوق هذا الموقع محفوظة لمجموعة آريو مجد آسيا التجارية الدولية."
},
"account": {
"login": "تسجيل الدخول",
"honorific": "العزيز",
"profile": "ملفي الشخصي",
"logout": "تسجيل الخروج",
"manageWebsite": "إدارة الموقع",
"menu": "الحساب"
},
"common": {
"skipToContent": "الانتقال إلى المحتوى الرئيسي",
"previous": "السابق",
"next": "التالي",
"goToSlide": "الانتقال إلى الشريحة",
"switchLanguage": "اللغة"
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { Dictionary } from "../types";
import raw from "./ar.json";
/* The annotation is the safety net: a key missing from ar.json fails the build. */
const ar: Dictionary = raw;
export default ar;
+335
View File
@@ -0,0 +1,335 @@
{
"meta": {
"company": "Ario Majd Asia International Trading Group",
"companyShort": "Ario Majd Asia Trading Group",
"companyLatin": "ARIO MAJD ASIA",
"tagline": "Your lasting partner in international trade",
"description": "Ario Majd Asia International Trading Group — technical and engineering services, trading, international transport, insurance and customs clearance."
},
"nav": {
"home": "Home",
"products": "Products",
"about": "About Us",
"contact": "Contact Us"
},
"slides": {
"company": {
"title": "Ario Majd Asia Trading Group",
"subtitle": "",
"description": "Technical and engineering services, trading and logistics on an international scale, built on our directors' experience and on partnerships with established Iranian and international companies."
},
"trade": {
"title": "Import and export of goods",
"subtitle": "",
"description": "Import, export and re-export of grain, foodstuffs, livestock inputs, oil derivatives, chemical and mineral products."
},
"partner": {
"title": "Your lasting partner in international trade",
"subtitle": "",
"description": "From sourcing and insurance to international transport and clearance through every customs office in the country — alongside you since 2019."
}
},
"services": {
"heading": "Our Services",
"headingLatin": "",
"subtitle": "The four core service areas of Ario Majd Asia International Trading Group",
"items": {
"transport": {
"title": "Transport and cargo insurance",
"latin": "",
"description": "International freight and insurance of commercial cargo through to destination."
},
"technical": {
"title": "Technical and engineering services",
"latin": "",
"description": "Urban planning, architecture, civil works, surveying, electrical and mechanical installations."
},
"clearance": {
"title": "Customs clearance",
"latin": "",
"description": "Clearance of permitted commercial goods through every customs office in the country."
},
"trade": {
"title": "Import and export of goods",
"latin": "",
"description": "Import, export and re-export across the group's specialist fields."
}
}
},
"products": {
"heading": "Products",
"headingLatin": "",
"lead": "The group's principal areas of commercial activity",
"goodsHeading": "Available goods",
"goodsLatin": "",
"servicesNote": "For every item in this category the group also handles international transport, cargo insurance and clearance through the country's customs offices.",
"otherHeading": "Other categories",
"otherLatin": "",
"inquiryHeading": "Request a quote for this category",
"inquiryBody": "Contact our commercial team for pricing, supply terms and delivery times.",
"inquiryButton": "Contact us",
"categories": {
"oil": {
"title": "Oil derivatives",
"latin": "",
"intro": "Oil derivatives are one of the group's principal areas of commercial activity. These products are sourced from Iranian refineries and production units, and exported with transport, insurance and clearance fully coordinated.",
"goods": [
"Bitumen",
"Base oil",
"Sulphur",
"Paraffin",
"Wax",
"Naphtha",
"Gas oil",
"Coal tar"
]
},
"mineral": {
"title": "Mineral products",
"latin": "",
"intro": "Iran ranks among the world's richest countries in mineral reserves. Drawing on its relationships with domestic mines and processing plants, the group handles the supply and export of mineral products across a range of tonnages.",
"goods": [
"Iron ore",
"Concentrate",
"Pellets",
"Gypsum",
"Feldspar",
"Barite",
"Bentonite",
"Silica"
]
},
"food": {
"title": "Food materials and products",
"latin": "",
"intro": "Import, export and re-export of grain and foodstuffs is one of the group's highest-volume activities. These goods are sourced from international markets and brought to the domestic market in line with health and quarantine standards.",
"goods": [
"Grain",
"Wheat",
"Rice",
"Maize",
"Sugar",
"Edible oil",
"Pulses",
"Dried fruit and nuts"
]
},
"chemical": {
"title": "Chemical products",
"latin": "",
"intro": "Chemical and polymer products are among the most important outputs of Iran's petrochemical industry. The group supplies these to domestic industrial units and exports them to regional markets.",
"goods": [
"Polyethylene",
"Polypropylene",
"PVC",
"Caustic soda flakes",
"Urea",
"Methanol",
"Chemical fertilisers",
"Resin"
]
},
"livestock": {
"title": "Livestock and poultry inputs",
"latin": "",
"intro": "Sourcing livestock and poultry inputs from international markets and clearing them through the country's customs offices is one of the group's specialist services for livestock farms, poultry units and feed mills.",
"goods": [
"Feed maize",
"Soybean meal",
"Barley",
"Bran",
"Fishmeal",
"Alfalfa",
"Feed supplements"
]
},
"construction": {
"title": "Building materials and equipment",
"latin": "",
"intro": "Alongside its technical and engineering services, the group supplies the materials, equipment and machinery required by civil and construction projects.",
"goods": [
"Cement",
"Rebar",
"I-beams",
"Tiles and ceramics",
"Sanitary fittings",
"Building insulation",
"Mechanical installations"
]
}
}
},
"home": {
"introHeading": "About Ario Majd Asia",
"introLatin": "",
"introBody": "Ario Majd Asia International Trading Group began formal operations in 2019, backed by partnerships with established Iranian and international companies and by its directors' experience in technical and engineering services, sourcing, marketing and international trade, insurance, international transport and the clearance of permitted commercial goods.",
"introCta": "Read more",
"partnersHeading": "Our Partners",
"partnersLatin": "",
"partnersSubtitle": "The bodies and organisations the group works with",
"ctaHeading": "We are ready to work with you",
"ctaBody": "Get in touch for advice on import, export, customs clearance and technical and engineering services.",
"ctaButton": "Contact us"
},
"about": {
"heading": "About Us",
"headingLatin": "",
"paragraphs": [
"Ario Majd Asia International Trading Group began formal operations in 2019, backed by partnerships with established Iranian and international companies and by its directors' experience in technical and engineering services, sourcing, marketing and international trade, insurance, international transport and the clearance of permitted commercial goods.",
"The group's technical and engineering work covers urban planning, architecture, civil engineering, surveying, transport and traffic, electrical and mechanical installations, and the supply of building materials, goods and equipment.",
"Its commercial activity centres on the import, export and re-export of grain, foodstuffs, livestock inputs, oil derivatives, chemical products, mineral products and home appliances, together with insurance, international transport and clearance through every customs office in the country.",
"Working with specialists and advisers across our legal, commercial, financial, industrial and project management, engineering, insurance, transport and clearance units, we are proud to be your lasting partner in international trade."
],
"vision": {
"heading": "Our vision",
"headingLatin": "",
"subtitle": "Where we intend to stand a decade from now",
"paragraphs": [
"Through the planning of its specialists and by drawing on new experience and opportunities in technical and engineering services, trade and logistics aligned with current world technology, Ario Majd Asia International Trading Group intends to be recognised within the coming decade as one of the country's ten leading, credible and dependable private companies.",
"By making international commercial cooperation easier through distinctive and varied services, and by earning the growing trust and satisfaction of domestic and international clients, we hope to take a step — however small — towards the economic progress and prosperity of Iran."
]
},
"mission": {
"heading": "Mission and goals",
"headingLatin": "",
"subtitle": "The principles that guide the group's daily work",
"items": [
"Placing client satisfaction at the head of everything we do",
"Employing experienced, educated staff and continually updating their knowledge, skills and wellbeing",
"Applying project management and cost management to the delivery of our services",
"Reducing the time, cost and risk of the technical, engineering, commercial and transport services we provide",
"Delivering technical, engineering, commercial and transport services of the highest quality",
"Reaching new global methods and technologies and transferring that expertise into the country, in step with the pace of world progress",
"Developing the technology and engineering knowledge related to the company's service and commercial activity",
"Becoming an outstanding organisation of international standing",
"Expanding our market through marketing and cooperation with established domestic and international companies",
"Advising clients so that information on target-market conditions and economic opportunities is easier to reach",
"Working with consulting and legal firms in target export countries to identify new opportunities and create favourable trading conditions for our clients",
"Developing the resources behind our services and commercial goods in step with market growth",
"A commitment to protecting the environment and a belief in sustainable development",
"Making local capability available to international companies"
]
},
"values": {
"heading": "Our values",
"headingLatin": "",
"subtitle": "What we hold to in every engagement",
"items": [
"Adherence to ethical and Islamic values",
"A sense of responsibility towards clients across our staff",
"Protecting and guaranteeing the security of client information",
"Protecting the interests of shareholders and staff",
"Rule of law and fairness",
"Honesty and trustworthiness",
"Respect for those we serve",
"Creativity and innovation",
"Merit-based advancement",
"Mutual trust"
]
}
},
"contact": {
"heading": "Contact Us",
"headingLatin": "",
"lead": "Get in touch with Ario Majd Asia",
"leadLatin": "",
"leadSubtitle": "Send us a message and our team will get back to you as soon as possible.",
"addressHeading": "Our address",
"addressLatin": "",
"emailHeading": "Email us",
"emailLatin": "",
"phoneHeading": "Call us",
"phoneLatin": "",
"socialHeading": "Stay with us",
"socialLatin": "",
"viewOnMap": "View on map",
"form": {
"name": "Full name",
"email": "Email",
"phone": "Phone number",
"subject": "Subject",
"message": "Your message",
"submit": "Send message",
"sending": "Sending…",
"success": "Your message has been received. We will be in touch shortly.",
"required": "This field is required.",
"invalidEmail": "Please enter a valid email address."
}
},
"blog": {
"heading": "News and articles",
"headingLatin": "",
"subtitle": "Notes from our specialists on trade, transport and customs clearance",
"readMore": "Read more",
"publishedOn": "Published on",
"viewAll": "All articles",
"backToList": "Back to all articles",
"empty": "No articles have been published yet."
},
"market": {
"heading": "Market today",
"headingLatin": "",
"subtitle": "Currency, gold and oil rates, with the status of the world's main exchanges",
"asOf": "Updated",
"source": "Source:",
"labels": {
"300106": "Gold ounce",
"400106": "Brent crude",
"200103": "USDT",
"200101": "USD (exchange)",
"200201": "EUR (exchange)"
},
"gold": {
"heading": "Gold by purity",
"toman": "Iran (Toman)",
"usd": "World (USD)",
"rows": {
"g24": "24k gold per gram",
"g22": "22k gold per gram",
"g18": "18k gold per gram",
"ounce": "World gold ounce"
}
},
"sessions": {
"heading": "World exchanges",
"open": "Open",
"closed": "Closed",
"reopens": "Reopens:",
"names": {
"newYork": "New York",
"london": "London",
"tokyo": "Tokyo"
}
}
},
"partners": {
"isipo": "Iran Small Industries and Industrial Parks Organization",
"mimt": "Ministry of Industry, Mine and Trade",
"iccima": "Iran Chamber of Commerce, Industries, Mines and Agriculture",
"qomccima": "Qom Chamber of Commerce, Industries, Mines and Agriculture",
"irica": "Islamic Republic of Iran Customs Administration",
"iranfair": "Iran International Exhibitions Company"
},
"footer": {
"linksHeading": "Sections",
"addressHeading": "Address",
"phoneHeading": "Phone",
"emailHeading": "Email",
"rights": "All rights to this website belong to Ario Majd Asia International Trading Group."
},
"account": {
"login": "Sign in",
"honorific": "",
"profile": "My profile",
"logout": "Sign out",
"manageWebsite": "Manage website",
"menu": "Account"
},
"common": {
"skipToContent": "Skip to main content",
"previous": "Previous",
"next": "Next",
"goToSlide": "Go to slide",
"switchLanguage": "Language"
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { Dictionary } from "../types";
import raw from "./en.json";
/* The annotation is the safety net: a key missing from en.json fails the build. */
const en: Dictionary = raw;
export default en;
+335
View File
@@ -0,0 +1,335 @@
{
"meta": {
"company": "گروه بازرگانی بین المللی آریو مجد آسیا",
"companyShort": "گروه بازرگانی آریو مجد آسیا",
"companyLatin": "ARIO MAJD ASIA",
"tagline": "همراه همیشگی تجارت بین المللی شما",
"description": "گروه بازرگانی بین المللی آریو مجد آسیا؛ ارائه دهنده خدمات فنی و مهندسی، بازرگانی، حمل و نقل بین المللی، بیمه و ترخیص کالا."
},
"nav": {
"home": "صفحه اصلی",
"products": "محصولات",
"about": "درباره ی ما",
"contact": "تماس با ما"
},
"slides": {
"company": {
"title": "گروه بازرگانی آریو مجد آسیا",
"subtitle": "ARIO MAJD ASIA",
"description": "ارائه خدمات فنی و مهندسی، بازرگانی و لجستیک در سطح بین المللی، با تکیه بر تجربه مدیران و همکاری با شرکت های معتبر داخلی و خارجی."
},
"trade": {
"title": "واردات و صادرات کالا",
"subtitle": "IMPORT AND EXPORT",
"description": "واردات، صادرات و صادرات مجدد غلات، مواد غذایی، نهاده های دامی، مشتقات نفتی، محصولات شیمیایی و معدنی."
},
"partner": {
"title": "همراه همیشگی تجارت بین المللی شما",
"subtitle": "YOUR TRADE PARTNER",
"description": "از تأمین و بیمه تا حمل و نقل بین المللی و ترخیص از کلیه گمرک های کشور؛ در کنار شما از سال ۱۳۹۸."
}
},
"services": {
"heading": "خدمات ما",
"headingLatin": "OUR SERVICES",
"subtitle": "چهار حوزه اصلی خدمات گروه بازرگانی بین المللی آریو مجد آسیا",
"items": {
"transport": {
"title": "حمل و نقل و بیمه کالاهای بازرگانی",
"latin": "TRANSPORT",
"description": "حمل و نقل بین المللی و بیمه کالاهای بازرگانی تا مقصد."
},
"technical": {
"title": "خدمات فنی و مهندسی",
"latin": "TECHNICAL SERVICES",
"description": "شهرسازی، معماری، عمران، نقشه برداری، تأسیسات برق و مکانیک."
},
"clearance": {
"title": "ترخیص کالا",
"latin": "DESIRED PRODUCTS",
"description": "ترخیص کالاهای مجاز بازرگانی از کلیه گمرک های کشور."
},
"trade": {
"title": "واردات و صادرات کالا",
"latin": "IMPORT AND EXPORT",
"description": "واردات، صادرات و صادرات مجدد در حوزه های تخصصی مجموعه."
}
}
},
"products": {
"heading": "محصولات",
"headingLatin": "PRODUCTS",
"lead": "زمینه های اصلی فعالیت بازرگانی مجموعه",
"goodsHeading": "اقلام قابل تأمین",
"goodsLatin": "AVAILABLE GOODS",
"servicesNote": "برای همه ی اقلام این دسته، خدمات حمل و نقل بین المللی، بیمه بار و ترخیص از گمرک های کشور نیز توسط مجموعه انجام می شود.",
"otherHeading": "سایر دسته ها",
"otherLatin": "OTHER CATEGORIES",
"inquiryHeading": "برای این دسته استعلام بگیرید",
"inquiryBody": "برای دریافت قیمت، شرایط تأمین و زمان تحویل، با کارشناسان بازرگانی مجموعه در تماس باشید.",
"inquiryButton": "تماس با ما",
"categories": {
"oil": {
"title": "مشتقات نفتی",
"latin": "OIL DERIVATIVES",
"intro": "مشتقات نفتی یکی از اصلی ترین حوزه های فعالیت بازرگانی گروه آریو مجد آسیا است. تأمین این محصولات از پالایشگاه ها و واحدهای تولیدی داخلی انجام می شود و صادرات آنها با هماهنگی کامل در زمینه حمل، بیمه و ترخیص صورت می گیرد.",
"goods": [
"قیر",
"روغن پایه",
"گوگرد",
"پارافین",
"وکس",
"نفتا",
"گازوئیل",
"قطران"
]
},
"mineral": {
"title": "محصولات معدنی",
"latin": "MINERAL PRODUCTS",
"intro": "ایران از نظر ذخایر معدنی در زمره کشورهای غنی جهان قرار دارد. مجموعه با تکیه بر ارتباط با معادن و واحدهای فرآوری داخلی، تأمین و صادرات محصولات معدنی را در تناژهای مختلف بر عهده می گیرد.",
"goods": [
"سنگ آهن",
"کنسانتره",
"گندله",
"سنگ گچ",
"فلدسپات",
"باریت",
"بنتونیت",
"سیلیس"
]
},
"food": {
"title": "مواد و محصولات غذایی",
"latin": "FOOD PRODUCTS",
"intro": "واردات، صادرات و صادرات مجدد غلات و مواد غذایی از حوزه های پرحجم فعالیت مجموعه است. تأمین این اقلام از بازارهای بین المللی و رساندن آنها به بازار داخلی با رعایت استانداردهای بهداشتی و قرنطینه ای انجام می شود.",
"goods": [
"غلات",
"گندم",
"برنج",
"ذرت",
"شکر",
"روغن خوراکی",
"حبوبات",
"خشکبار"
]
},
"chemical": {
"title": "محصولات شیمیایی",
"latin": "CHEMICAL PRODUCTS",
"intro": "محصولات شیمیایی و پلیمری از مهم ترین تولیدات صنعت پتروشیمی کشور به شمار می روند. مجموعه تأمین این اقلام برای واحدهای صنعتی داخلی و صادرات آنها به بازارهای منطقه را انجام می دهد.",
"goods": [
"پلی اتیلن",
"پلی پروپیلن",
"پی وی سی",
"سود پرک",
"اوره",
"متانول",
"کودهای شیمیایی",
"رزین"
]
},
"livestock": {
"title": "نهاده های دام و طیور",
"latin": "LIVESTOCK INPUTS",
"intro": "تأمین نهاده های دام و طیور از بازارهای بین المللی و ترخیص آنها از گمرک های کشور، از خدمات تخصصی مجموعه برای واحدهای دامپروری، مرغداری و کارخانه های خوراک دام است.",
"goods": [
"ذرت دامی",
"کنجاله سویا",
"جو",
"سبوس",
"پودر ماهی",
"یونجه",
"مکمل های دامی"
]
},
"construction": {
"title": "مصالح و تجهیزات ساختمانی",
"latin": "BUILDING MATERIALS",
"intro": "در کنار خدمات فنی و مهندسی، مجموعه تأمین مصالح، تجهیزات و ماشین آلات مورد نیاز پروژه های عمرانی و ساختمانی را نیز بر عهده می گیرد.",
"goods": [
"سیمان",
"میلگرد",
"تیرآهن",
"کاشی و سرامیک",
"شیرآلات",
"عایق های ساختمانی",
"تجهیزات تأسیساتی"
]
}
}
},
"home": {
"introHeading": "درباره آریو مجد آسیا",
"introLatin": "ABOUT US",
"introBody": "گروه بازرگانی بین المللی آریو مجد آسیا به پشتوانه همکاری با شرکت های معتبر داخلی و بین المللی و همچنین تجربه مدیران مجموعه در زمینه ارائه خدمات فنی و مهندسی، تأمین، بازاریابی و تجارت بین المللی، بیمه، حمل و نقل بین المللی و خدمات ترخیص کالاهای مجاز بازرگانی؛ فعالیت رسمی خود را در سال ۱۳۹۸ آغاز نموده است.",
"introCta": "بیشتر بدانید",
"partnersHeading": "همکاران ما",
"partnersLatin": "OUR PARTNERS",
"partnersSubtitle": "نهادها و سازمان هایی که مجموعه با آنها در ارتباط است",
"ctaHeading": "آماده همکاری با شما هستیم",
"ctaBody": "برای دریافت مشاوره در زمینه واردات، صادرات، ترخیص کالا و خدمات فنی و مهندسی با ما در تماس باشید.",
"ctaButton": "تماس با ما"
},
"about": {
"heading": "درباره ما",
"headingLatin": "ABOUT US",
"paragraphs": [
"گروه بازرگانی بین المللی آریو مجد آسیا به پشتوانه همکاری با شرکت های معتبر داخلی و بین المللی و همچنین تجربه مدیران مجموعه در زمینه ارائه خدمات فنی و مهندسی، تأمین، بازاریابی و تجارت بین المللی، بیمه، حمل و نقل بین المللی و خدمات ترخیص کالاهای مجاز بازرگانی؛ فعالیت رسمی خود را در سال ۱۳۹۸ آغاز نموده است.",
"عمده خدمات فنی و مهندسی مجموعه در زمینه شهرسازی، معماری، عمران، نقشه برداری، حمل و نقل و ترافیک، تأسیسات برق و تأسیسات مکانیک و تأمین کالاها، تجهیزات و مصالح ساختمانی می باشد.",
"عمده فعالیت های بازرگانی مجموعه نیز در زمینه واردات، صادرات و صادرات مجدد غلات، مواد غذایی، نهاده های دامی، مشتقات نفتی، محصولات شیمیایی، محصولات معدنی و لوازم خانگی و بیمه، حمل و نقل بین المللی و ترخیص آنها از کلیه گمرک های کشور می باشد.",
"مفتخریم با همکاری کارشناسان و مشاوران واحدهای حقوقی، بازرگانی، مالی، مدیریت صنایع و پروژه، مهندسی، بیمه، حمل و نقل و ترخیص کالا در گروه بازرگانی بین المللی آریو مجد آسیا؛ همراه همیشگی تجارت بین المللی شما باشیم."
],
"vision": {
"heading": "چشم انداز",
"headingLatin": "PERSPECTIVE",
"subtitle": "جایگاهی که در یک دهه آینده برای مجموعه ترسیم کرده ایم",
"paragraphs": [
"گروه بازرگانی بین المللی آریو مجد آسیا با برنامه ریزی های متخصصین خود و با بهره گیری از تجربیات و فرصت های نوین در ارائه خدمات فنی و مهندسی، بازرگانی و لجستیک منطبق با تکنولوژی های روز جهان؛ در یک دهه آینده به عنوان ۱۰ شرکت خصوصی برتر، معتبر و قابل اعتماد کشور شناخته خواهد شد.",
"امید است با آسان سازی همکاری های بازرگانی بین المللی به واسطه ارائه خدمات متمایز، متنوع و کسب اعتبار و رضایت روزافزون مشتریان داخلی و بین المللی، قدمی هرچند کوچک در راستای پیشرفت و اعتلای اهداف اقتصادی و آبادانی میهن عزیزمان ایران برداریم."
]
},
"mission": {
"heading": "مأموریت و اهداف",
"headingLatin": "MISSION AND GOALS",
"subtitle": "اصولی که فعالیت روزانه مجموعه را هدایت می کند",
"items": [
"قراردادن جلب رضایت مشتریان در سرلوحه کارهای خود",
"بکارگیری پرسنل مجرب و تحصیلکرده و همچنین به روز رسانی دانش و ارتقای مهارت و سطح سلامت آنان",
"بهره گیری از مدیریت پروژه و مدیریت هزینه در ارائه خدمات",
"کاهش زمان، هزینه و ریسک در ارائه خدمات فنی و مهندسی، بازرگانی و حمل و نقلی به مشتریان",
"ارائه بهترین و با کیفیت ترین خدمات فنی و مهندسی، بازرگانی و حمل و نقلی به مشتریان",
"دستیابی به روشها و فناوری نوین جهانی و انتقال دانش فنی آنها به کشور، همگام با روند سریع پیشرفت های جهانی",
"توسعه تکنولوژیکی و دانش مهندسی مرتبط با فعالیت شرکت در بخش های خدماتی و بازرگانی",
"تبدیل شدن به یک سازمان متعالی و سرآمد در سطح بین المللی",
"توسعه حوزه بازار از طریق بازاریابی و همکاری با شرکت های معتبر داخلی و بین المللی",
"مشاوره جهت ایجاد و امکان دستیابی آسانتر به اطلاعات و شرایط و موقعیتهای اقتصادی بازار هدف",
"همکاری با شرکتهای مشاوره ای و حقوقی در کشورهای هدف صادراتی جهت شناخت و معرفی فرصت های جدید و فراهم آوردن شرایط مناسب بازرگانی برای مشتریان و رضایت مندی آنان",
"توسعه منابع ارائه و تأمین خدمات و کالای تجاری متناسب با توسعه بازار",
"تعهد به حفظ محیط زیست و اعتقاد به توسعه پایدار",
"استفاده از امکانات محلی برای شرکت های بین المللی"
]
},
"values": {
"heading": "ارزش های سازمان",
"headingLatin": "VALUES OF THE ORGANIZATION",
"subtitle": "آنچه در هر همکاری به آن پایبند هستیم",
"items": [
"پایبندی به ارزش های اخلاقی و اسلامی",
"احساس مسئولیت پرسنل نسبت به مشتریان",
"حفظ و تضمین امنیت اطلاعات مشتریان",
"حفظ منافع سهامداران و پرسنل شرکت",
"قانون مداری و عدالت محوری",
"صداقت و امانت داری",
"تکریم ارباب رجوع",
"خلاقیت و نوآوری",
"شایسته سالاری",
"اعتماد متقابل"
]
}
},
"contact": {
"heading": "تماس با ما",
"headingLatin": "CONTACT US",
"lead": "ارتباط با آریو مجد آسیا",
"leadLatin": "CONTACT WITH ARIO MAJD ASIA",
"leadSubtitle": "پیام خود را ارسال کنید؛ کارشناسان ما در اسرع وقت پاسخگوی شما خواهند بود.",
"addressHeading": "نشانی ما",
"addressLatin": "ADDRESS",
"emailHeading": "به ما ایمیل بزنید",
"emailLatin": "EMAIL",
"phoneHeading": "اطلاعات تماس با ما",
"phoneLatin": "CONTACT US",
"socialHeading": "با ما همراه باشید",
"socialLatin": "STAY WITH US",
"viewOnMap": "مشاهده روی نقشه",
"form": {
"name": "نام و نام خانوادگی",
"email": "ایمیل",
"phone": "شماره تماس",
"subject": "موضوع",
"message": "پیام شما",
"submit": "ارسال پیام",
"sending": "در حال ارسال…",
"success": "پیام شما با موفقیت ثبت شد. به زودی با شما تماس می گیریم.",
"required": "تکمیل این فیلد الزامی است.",
"invalidEmail": "لطفاً یک ایمیل معتبر وارد کنید."
}
},
"blog": {
"heading": "اخبار و مقالات",
"headingLatin": "BLOG",
"subtitle": "یادداشت های کارشناسان مجموعه در حوزه بازرگانی، حمل و نقل و ترخیص کالا",
"readMore": "ادامه مطلب",
"publishedOn": "تاریخ انتشار",
"viewAll": "همه ی مطالب",
"backToList": "بازگشت به فهرست مطالب",
"empty": "هنوز مطلبی منتشر نشده است."
},
"market": {
"heading": "بازار امروز",
"headingLatin": "MARKET TODAY",
"subtitle": "نرخ های ارز، طلا و نفت به همراه وضعیت بازارهای بورس جهانی",
"asOf": "به روزرسانی",
"source": "منبع:",
"labels": {
"300106": "انس طلا",
"400106": "نفت برنت",
"200103": "دلار تتر",
"200101": "دلار صرافی",
"200201": "یورو صرافی"
},
"gold": {
"heading": "عیارهای مختلف طلا",
"toman": "بازار ایران (تومان)",
"usd": "بازار جهانی (دلار)",
"rows": {
"g24": "هر گرم طلای ۲۴ عیار",
"g22": "هر گرم طلای ۲۲ عیار",
"g18": "هر گرم طلای ۱۸ عیار",
"ounce": "اونس جهانی طلا"
}
},
"sessions": {
"heading": "وضعیت بورس های جهانی",
"open": "باز",
"closed": "بسته",
"reopens": "بازگشایی:",
"names": {
"newYork": "نیویورک",
"london": "لندن",
"tokyo": "توکیو"
}
}
},
"partners": {
"isipo": "سازمان صنایع کوچک و شهرک های صنعتی ایران",
"mimt": "وزارت صنعت، معدن و تجارت",
"iccima": "اتاق بازرگانی، صنایع، معادن و کشاورزی ایران",
"qomccima": "اتاق بازرگانی، صنایع، معادن و کشاورزی قم",
"irica": "گمرک جمهوری اسلامی ایران",
"iranfair": "نمایشگاه بین المللی جمهوری اسلامی ایران"
},
"footer": {
"linksHeading": "زیر شاخه ها",
"addressHeading": "ادرس",
"phoneHeading": "شماره تماس",
"emailHeading": "ایمیل",
"rights": "کلیه حقوق این وب سایت متعلق به گروه بازرگانی بین المللی آریو مجد آسیا است."
},
"account": {
"login": "ورود",
"honorific": "عزیز",
"profile": "پروفایل من",
"logout": "خروج",
"manageWebsite": "مدیریت وبسایت",
"menu": "حساب کاربری"
},
"common": {
"skipToContent": "رفتن به محتوای اصلی",
"previous": "قبلی",
"next": "بعدی",
"goToSlide": "رفتن به اسلاید",
"switchLanguage": "زبان"
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { Dictionary } from "../types";
import raw from "./fa.json";
/* The annotation is the safety net: a key missing from fa.json fails the build. */
const fa: Dictionary = raw;
export default fa;
+159
View File
@@ -0,0 +1,159 @@
import type { ProductSlug, ServiceId, SlideId } from "@/content";
import type { PartnerId } from "@/lib/site";
import type { GoldKey, SessionKey } from "@/lib/market/types";
/**
* Shape of a locale file. Prose only — no image paths, no slugs, no ordering.
*
* Each locale's JSON is assigned to this type in its wrapper module, so a
* missing key is a build error. (Assignment does not flag *extra* keys; the
* `npm run i18n:check` script covers stale ones left behind after an edit.)
*/
export type Dictionary = {
meta: {
company: string;
companyShort: string;
companyLatin: string;
tagline: string;
description: string;
};
nav: { home: string; products: string; about: string; contact: string };
slides: Record<SlideId, { title: string; subtitle: string; description: string }>;
services: {
heading: string;
headingLatin: string;
subtitle: string;
items: Record<ServiceId, { title: string; latin: string; description: string }>;
};
products: {
heading: string;
headingLatin: string;
lead: string;
goodsHeading: string;
goodsLatin: string;
servicesNote: string;
otherHeading: string;
otherLatin: string;
inquiryHeading: string;
inquiryBody: string;
inquiryButton: string;
categories: Record<
ProductSlug,
{ title: string; latin: string; intro: string; goods: string[] }
>;
};
home: {
introHeading: string;
introLatin: string;
introBody: string;
introCta: string;
partnersHeading: string;
partnersLatin: string;
partnersSubtitle: string;
ctaHeading: string;
ctaBody: string;
ctaButton: string;
};
about: {
heading: string;
headingLatin: string;
paragraphs: string[];
vision: { heading: string; headingLatin: string; subtitle: string; paragraphs: string[] };
mission: { heading: string; headingLatin: string; subtitle: string; items: string[] };
values: { heading: string; headingLatin: string; subtitle: string; items: string[] };
};
contact: {
heading: string;
headingLatin: string;
lead: string;
leadLatin: string;
leadSubtitle: string;
addressHeading: string;
addressLatin: string;
emailHeading: string;
emailLatin: string;
phoneHeading: string;
phoneLatin: string;
socialHeading: string;
socialLatin: string;
viewOnMap: string;
form: {
name: string;
email: string;
phone: string;
subject: string;
message: string;
submit: string;
sending: string;
success: string;
required: string;
invalidEmail: string;
};
};
/** Labels only. Posts themselves are single-language — see src/lib/posts.ts. */
blog: {
heading: string;
headingLatin: string;
subtitle: string;
readMore: string;
viewAll: string;
backToList: string;
publishedOn: string;
/** Shown when the backend has no published posts yet. */
empty: string;
};
/**
* Market ticker chrome. `labels` is keyed by upstream instrument id and is
* intentionally partial — add an entry only for the ids listed in
* `src/lib/market/displayed.ts`; anything missing falls back to the upstream
* Persian label.
*/
market: {
heading: string;
headingLatin: string;
subtitle: string;
asOf: string;
source: string;
/** Keyed by upstream instrument id — see `src/lib/market/displayed.ts`. */
labels: Record<string, string>;
gold: {
heading: string;
toman: string;
usd: string;
rows: Record<GoldKey, string>;
};
sessions: {
heading: string;
open: string;
closed: string;
reopens: string;
names: Record<SessionKey, string>;
};
};
/** Partner organisation names, keyed by the ids in `src/lib/site.ts`. */
partners: Record<PartnerId, string>;
footer: {
linksHeading: string;
addressHeading: string;
phoneHeading: string;
emailHeading: string;
rights: string;
};
/** Account menu. Sign-in itself happens on the customer portal. */
account: {
login: string;
/** Appended after the user's name, e.g. "علی عزیز". */
honorific: string;
profile: string;
logout: string;
manageWebsite: string;
menu: string;
};
common: {
skipToContent: string;
previous: string;
next: string;
goToSlide: string;
switchLanguage: string;
};
};
+54
View File
@@ -0,0 +1,54 @@
/**
* Meshkee Website API client.
*
* Contract: https://api.meshkee.com/docs/website
* Public content lives under /tenants/{domain}/… and needs no auth.
*/
export const API_BASE = process.env.NEXT_PUBLIC_MESHKEE_API ?? "https://api.meshkee.com/api/v1";
/** Apex host only — no www / api / customer / business prefix. */
export const TENANT_DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN ?? "majdtrading.com";
/** Where the login link sends people. The portal lives on its own subdomain. */
export const CUSTOMER_PORTAL = `https://customer.${TENANT_DOMAIN}`;
type FetchOptions = {
/** Seconds. Public content is cached; per-user calls must pass `false`. */
revalidate?: number | false;
token?: string;
};
/**
* Returns null instead of throwing. A marketing page must still render when the
* API is down — every caller degrades to hiding its section.
*/
export async function apiGet<T>(path: string, options: FetchOptions = {}): Promise<T | null> {
const { revalidate = 300, token } = options;
try {
const response = await fetch(`${API_BASE}${path}`, {
headers: {
Accept: "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...(revalidate === false
? { cache: "no-store" as const }
: { next: { revalidate } }),
});
if (!response.ok) {
// 401 on /auth/me is the normal "signed out" path, not an incident.
if (response.status !== 401) {
console.error(`[api] ${path} responded ${response.status}`);
}
return null;
}
return (await response.json()) as T;
} catch (error) {
console.error(`[api] ${path} failed:`, (error as Error).message);
return null;
}
}
export const tenantPath = (path: string) => `/tenants/${TENANT_DOMAIN}${path}`;
+73
View File
@@ -0,0 +1,73 @@
import "server-only";
import { cookies } from "next/headers";
import { apiGet } from "./api";
import type { Session } from "./portal";
/**
* Who is signed in, as far as this site can tell.
*
* Sign-in happens on the customer portal (a different subdomain), so this site
* never sees a password and never calls POST /auth/login. It only reads a
* session token and asks the API who it belongs to.
*
* ── The open question ────────────────────────────────────────────────────────
* The Meshkee API authenticates with a Bearer JWT, which portals usually keep
* in localStorage — and localStorage is NOT shared across subdomains. So for
* majdtrading.com to see a session created on customer.majdtrading.com, the
* portal must write the token to a cookie scoped to the apex (`.majdtrading.com`,
* readable by both hosts).
*
* The API docs do not say whether it does, or under what name. `SESSION_COOKIE`
* below is the single place to correct once that is confirmed. Until it matches,
* this returns null and the header shows the sign-in link — which is the correct
* fallback, just not the whole feature.
*/
export const SESSION_COOKIE = process.env.MESHKEE_SESSION_COOKIE ?? "meshkee_token";
/** Roles the API may use for someone who can administer the website. */
const ADMIN_ROLES = new Set(["admin", "owner", "superadmin", "business_owner"]);
/**
* Shape-tolerant on purpose: /auth/me has no documented response schema and
* needs a token we do not hold, so it could not be sampled. These are the
* plausible spellings; unknown extras are ignored rather than crashing.
*/
type ApiMe = {
firstName?: string | null;
lastName?: string | null;
name?: string | null;
fullName?: string | null;
cellNumber?: string | null;
role?: string | null;
roles?: string[] | null;
isAdmin?: boolean | null;
user?: ApiMe | null;
};
function pickName(me: ApiMe): string | null {
const composed = [me.firstName, me.lastName].filter(Boolean).join(" ").trim();
return composed || me.fullName?.trim() || me.name?.trim() || me.cellNumber?.trim() || null;
}
function pickAdmin(me: ApiMe): boolean {
if (me.isAdmin === true) return true;
const roles = [me.role, ...(me.roles ?? [])].filter(Boolean) as string[];
return roles.some((role) => ADMIN_ROLES.has(role.toLowerCase()));
}
export async function getSession(): Promise<Session | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
// Never cached — this is per-visitor.
const data = await apiGet<ApiMe>("/auth/me", { token, revalidate: false });
if (!data) return null;
const me = data.user ?? data;
const displayName = pickName(me);
if (!displayName) return null;
return { displayName, isAdmin: pickAdmin(me) };
}
export type { Session };
+66
View File
@@ -0,0 +1,66 @@
import { apiGet, tenantPath } from "./api";
import { slugify } from "./slug";
/**
* Blog posts, from the Meshkee backend.
*
* Note the two endpoints disagree in shape: the list returns `{ items: [...] }`
* and the detail returns `{ blog: {...} }`. Both are normalised here so callers
* see one type.
*
* Posts are single-language by design — the backend stores one body per post,
* with no locale variants. See README, "Multi-language".
*/
export type BlogPost = {
id: number;
/** Canonical, built from the title. Never the API's own legacy `slug`. */
slug: string;
title: string;
abstract: string | null;
bodyHtml: string | null;
imageUrl: string | null;
categoryName: string | null;
author: string | null;
publishedAt: string | null;
};
type ApiBlog = {
id: number;
slug: string;
title: string;
abstract?: string | null;
mainTextHtml?: string | null;
titleImageUrl?: string | null;
categoryName?: string | null;
author?: { firstName?: string | null; lastName?: string | null } | null;
publishedAt?: string | null;
createdAt?: string | null;
};
function normalise(blog: ApiBlog): BlogPost {
const name = [blog.author?.firstName, blog.author?.lastName].filter(Boolean).join(" ").trim();
return {
id: blog.id,
slug: slugify(blog.title),
title: blog.title,
abstract: blog.abstract ?? null,
bodyHtml: blog.mainTextHtml ?? null,
imageUrl: blog.titleImageUrl ?? null,
categoryName: blog.categoryName ?? null,
author: name || null,
publishedAt: blog.publishedAt ?? blog.createdAt ?? null,
};
}
export async function getPosts(limit = 12): Promise<BlogPost[]> {
const data = await apiGet<{ items?: ApiBlog[] }>(
tenantPath(`/blogs?page=1&pageSize=${limit}`)
);
return (data?.items ?? []).map(normalise);
}
/** Detail pages resolve by id — the slug segment is decorative. */
export async function getPostById(id: number): Promise<BlogPost | null> {
const data = await apiGet<{ blog?: ApiBlog }>(tenantPath(`/blogs/by-id/${id}`));
return data?.blog ? normalise(data.blog) : null;
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Column count that follows the data.
*
* Tailwind extracts class names as literal strings, so `lg:grid-cols-${n}` never
* reaches the stylesheet. Mapping a length onto static classes keeps the grid
* matching the number of items — adding or removing a category no longer leaves
* an empty column behind, which is exactly what happened when the home
* appliances category was dropped from a hard-coded seven.
*/
const columnsByCount: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
5: "lg:grid-cols-5",
6: "lg:grid-cols-6",
7: "lg:grid-cols-7",
8: "lg:grid-cols-8",
};
export function lgColumns(count: number, fallback = "lg:grid-cols-6"): string {
return columnsByCount[count] ?? fallback;
}
+22
View File
@@ -0,0 +1,22 @@
import type { InstrumentId } from "./instruments";
/**
* THE ONE LIST TO EDIT.
*
* Which figures appear in the market frame, in this order. Empty means the frame
* is not rendered at all — no gap, no placeholder.
*
* Add ids from `instruments.ts`, and a label for each under `market.labels` in
* every `src/i18n/locales/*.json`; without a translation the upstream Persian
* label is used as a fallback.
*
* The gold table and the session list have their own parsers and are always
* included. Note the gold table's last row is the world ounce (id 300106), so
* listing that id here too would print the same figure twice.
*/
export const displayed: InstrumentId[] = [
"400106", // نفت برنت
"200103", // دلار تتر
"200101", // دلار - صرافی
"200201", // یورو - صرافی
];
+23
View File
@@ -0,0 +1,23 @@
import { displayed } from "./displayed";
import { livedataSource } from "./livedata";
import type { MarketSnapshot, MarketSource } from "./types";
/** Swap this line to change where the figures come from. */
const source: MarketSource = livedataSource;
/**
* Never throws. Nothing selected, or an unreachable upstream, returns null and
* the caller renders nothing — a build must not depend on a third party being
* up, and a visitor should never see a broken widget.
*/
export async function getMarketSnapshot(): Promise<MarketSnapshot | null> {
try {
return await source.fetchSnapshot(displayed);
} catch (error) {
console.error("[market] snapshot unavailable:", (error as Error).message);
return null;
}
}
export { instruments, type InstrumentId } from "./instruments";
export * from "./types";
+56
View File
@@ -0,0 +1,56 @@
/**
* Catalogue of everything livedata.ir publishes, keyed by its stable upstream id.
*
* Ids are the anchor because the page is ad-heavy and its layout shifts, but an
* id has a fixed meaning. `fa` is the label as published upstream — used to
* identify the row when choosing, and as a fallback if no translation exists yet.
*
* Nothing here is displayed on its own. Pick what to show in `displayed.ts`.
*/
export const instruments = {
"200101": { fa: "دلار - صرافی" },
"200103": { fa: "دلار تتر" },
"200201": { fa: "یورو - صرافی" },
"2004": { fa: "درهم امارات" },
"300106": { fa: "انس طلا" },
"300206": { fa: "نقره" },
"300306": { fa: "پلاتین" },
"400106": { fa: "نفت برنت" },
"400206": { fa: "نفت سبک آمریکا" },
"6001": { fa: "بورس تهران" },
"600206": { fa: "داوجونز" },
"600306": { fa: "اس اند پی" },
"600406": { fa: "نزدک" },
"600506": { fa: "کک فرانسه" },
"600606": { fa: "فوتسی بریتانیا" },
"600706": { fa: "استاکس اروپا" },
"600806": { fa: "نیکی ژاپن" },
"600906": { fa: "استرالیا" },
"601006": { fa: "دکس آلمان" },
"601106": { fa: "شانگهای چین" },
"601206": { fa: "بمبئی هند" },
"601306": { fa: "بورس سوئیس" },
"601406": { fa: "تداول عربستان" },
"601506": { fa: "استانبول" },
"601606": { fa: "هنگ سنگ" },
"700007": { fa: "شاخص دلار" },
"700106": { fa: "یورودلار" },
"700206": { fa: "پوند به دلار" },
"700306": { fa: "دلار استرالیا به دلار آمریکا" },
"700406": { fa: "دلار آمریکا به کانادا" },
"700506": { fa: "دلار به ین" },
"700607": { fa: "دلار به روپیه" },
"700706": { fa: "یورو به پوند" },
"700806": { fa: "یورو به ین" },
"700906": { fa: "دلار به لیر" },
"701107": { fa: "دلار به یوان چین" },
"701207": { fa: "دلار به درهم" },
"701607": { fa: "دلار به روبل" },
"701707": { fa: "بیت کوین" },
} as const;
export type InstrumentId = keyof typeof instruments;
/* Also published but unlabelled upstream, so left out until identified:
300406, 300606, 300706, 300806, 300906, 301006, 301106, 400306, 400406, 400506, 500106, 500206, 500306, 500407, 500607, 500706, 500806, 500907
*/
+164
View File
@@ -0,0 +1,164 @@
import type { InstrumentId } from "./instruments";
import {
goldKeys,
type Direction,
type GoldRow,
type MarketSnapshot,
type MarketSource,
type Quote,
type Session,
type SessionKey,
} from "./types";
const SOURCE_URL = "https://livedata.ir/";
/* ── shared helpers ─────────────────────────────────────────────────────── */
const stripTags = (value: string) => value.replace(/<[^>]*>/g, "").trim();
function directionOf(change: string): Direction {
const value = Number.parseFloat(change.replace(/,/g, ""));
if (!Number.isFinite(value) || value === 0) return "flat";
return value > 0 ? "up" : "down";
}
/* ── 1. id-anchored figures ─────────────────────────────────────────────── */
function readSpan(html: string, prefix: string, id: string) {
const match = html.match(new RegExp(`id="${prefix}_${id}"[^>]*>([^<]*)<`));
return match ? match[1].trim() : null;
}
function parseQuotes(html: string, ids: readonly InstrumentId[]): Quote[] {
const quotes: Quote[] = [];
for (const id of ids) {
const price = readSpan(html, "s", id);
// An instrument that moved or vanished upstream is skipped, not fatal.
if (!price) continue;
const change = readSpan(html, "c", id) ?? "0";
quotes.push({
id,
price,
change,
changePercent: readSpan(html, "cp", id) ?? "0%",
direction: directionOf(change),
});
}
return quotes;
}
/* ── 2. gold purities ───────────────────────────────────────────────────── */
/**
* This table carries no ids, so it is anchored on its section heading and then
* on each row's fixed Persian label. More brittle than the id-based figures —
* if upstream renames a row, that row drops out and the rest survive.
*/
const GOLD_ROW_LABELS: Record<string, (typeof goldKeys)[number]> = {
"هر گرم 24": "g24",
"هر گرم 22": "g22",
"هر گرم 18": "g18",
};
/** Upstream id for the world gold ounce, shown as the table's last row. */
const GOLD_OUNCE_ID = "300106";
function parseGold(html: string): GoldRow[] {
const start = html.indexOf("قیمت عیارهای مختلف طلا");
if (start === -1) return [];
const table = html.slice(start, html.indexOf("</table>", start));
const rows: GoldRow[] = [];
const pattern = /<td class="tabna">([^<]+)<\/td>\s*<td[^>]*>([^<]*)<\/td>\s*<td[^>]*>([^<]*)<\/td>/g;
for (const match of table.matchAll(pattern)) {
const key = GOLD_ROW_LABELS[stripTags(match[1])];
if (!key) continue;
rows.push({ key, toman: match[2].trim(), usd: match[3].trim() });
}
// The world ounce lives elsewhere on the page, under its own id.
const ouncePrice = readSpan(html, "s", GOLD_OUNCE_ID);
if (ouncePrice) rows.push({ key: "ounce", toman: null, usd: ouncePrice });
// Keep our declared order rather than the upstream's.
return goldKeys
.map((key) => rows.find((row) => row.key === key))
.filter((row): row is GoldRow => Boolean(row));
}
/* ── 3. exchange sessions ───────────────────────────────────────────────── */
const SESSION_NAMES: Record<string, SessionKey> = {
نیویورک: "newYork",
لندن: "london",
توکیو: "tokyo",
};
function parseSessions(html: string): Session[] {
const start = html.indexOf("ساعت و وضعیت کاری");
if (start === -1) return [];
const block = html.slice(start, html.indexOf("</ul>", start));
const sessions: Session[] = [];
// name, then the status icon class, then optional reopening date after the text
const pattern =
/<span class="nma">([^<]+)<\/span>\s*<span class="sp ([^"]*)"><\/span>\s*<span>[^<]*<\/span>\s*([^<]*)/g;
for (const match of block.matchAll(pattern)) {
const key = SESSION_NAMES[stripTags(match[1])];
if (!key) continue;
const closed = /close/i.test(match[2]);
const reopensOn = match[3].trim();
sessions.push({ key, open: !closed, reopensOn: closed && reopensOn ? reopensOn : null });
}
return sessionKeysOrder(sessions);
}
function sessionKeysOrder(sessions: Session[]) {
const order: SessionKey[] = ["newYork", "london", "tokyo"];
return order
.map((key) => sessions.find((session) => session.key === key))
.filter((session): session is Session => Boolean(session));
}
/* ── source ─────────────────────────────────────────────────────────────── */
/**
* livedata.ir publishes no API — the figures sit in a static page its server
* regenerates once a minute, on the minute. We read only the numbers and label
* them ourselves, in our own three languages.
*/
export const livedataSource: MarketSource = {
name: "livedata.ir",
url: SOURCE_URL,
async fetchSnapshot(ids: readonly InstrumentId[]): Promise<MarketSnapshot> {
const response = await fetch(SOURCE_URL, {
// One upstream request per minute for the whole site, not one per visitor.
// Matches their regeneration cadence — polling faster gains nothing.
next: { revalidate: 60 },
headers: { "User-Agent": "majdtrading.com market widget" },
});
if (!response.ok) throw new Error(`livedata.ir responded ${response.status}`);
const html = await response.text();
const quotes = parseQuotes(html, ids);
const gold = parseGold(html);
const sessions = parseSessions(html);
if (quotes.length === 0 && gold.length === 0 && sessions.length === 0) {
throw new Error("livedata.ir markup produced nothing — parsers need updating");
}
return {
quotes,
gold,
sessions,
fetchedAt: new Date().toISOString(),
source: { name: livedataSource.name, url: SOURCE_URL },
};
},
};
+55
View File
@@ -0,0 +1,55 @@
import type { InstrumentId } from "./instruments";
export type Direction = "up" | "down" | "flat";
/** A single figure from the id-anchored part of the page. */
export type Quote = {
id: InstrumentId;
/** Kept as published — formatted decimal strings, not numbers to compute with. */
price: string;
change: string;
changePercent: string;
direction: Direction;
};
export const goldKeys = ["g24", "g22", "g18", "ounce"] as const;
export type GoldKey = (typeof goldKeys)[number];
/**
* Gold by purity: Toman on the domestic market, USD on the world market.
*
* `toman` is null for the world ounce — upstream publishes no Toman price for
* it, and deriving one from the gram rate would be inventing a figure.
*/
export type GoldRow = { key: GoldKey; toman: string | null; usd: string };
export const sessionKeys = ["newYork", "london", "tokyo"] as const;
export type SessionKey = (typeof sessionKeys)[number];
/**
* Trading session state. Taken from upstream rather than computed from opening
* hours, because upstream accounts for public holidays — it is currently
* reporting Monday reopenings across a weekend.
*/
export type Session = { key: SessionKey; open: boolean; reopensOn: string | null };
export type MarketSnapshot = {
quotes: Quote[];
gold: GoldRow[];
sessions: Session[];
/** When we fetched, not when the upstream last recomputed. */
fetchedAt: string;
source: { name: string; url: string };
};
/**
* One method, so the data source is a single swappable file.
*
* When the company backend exists, add `src/lib/market/backend.ts` implementing
* this and change one import in `index.ts` — nothing else moves.
*/
export interface MarketSource {
readonly name: string;
readonly url: string;
fetchSnapshot(ids: readonly InstrumentId[]): Promise<MarketSnapshot>;
}
+17
View File
@@ -0,0 +1,17 @@
import { CUSTOMER_PORTAL } from "./api";
/**
* Plain values only — no `next/headers`, so client components can import this.
* Session *reading* lives in `auth.ts`, which is server-only.
*/
export type Session = {
displayName: string;
isAdmin: boolean;
};
export const portalLinks = {
login: `${CUSTOMER_PORTAL}/auth/login`,
profile: `${CUSTOMER_PORTAL}/profile`,
logout: `${CUSTOMER_PORTAL}/auth/logout`,
admin: `${CUSTOMER_PORTAL}/admin`,
};
+34
View File
@@ -0,0 +1,34 @@
/** Company details, shared by every locale — sourced from majdtrading.com. */
export const site = {
url: "https://www.majdtrading.com",
address: "ایران- قم- بلوار جمهوری اسلامی- میدان سپاه",
phones: ["+989101920966", "+989112199185"],
phonesDisplay: ["(+98) 910 192 0966", "(+98) 911 219 9185"],
emails: ["ceo.majd@hotmail.com", "commercial.majd@hotmail.com"],
instagram: {
handle: "@ariomajdasia",
url: "https://www.instagram.com/ariomajdasia/",
},
foundedYear: 1398,
/** Sepah Square, Qom — used for the contact-page map. */
map: {
lat: 34.6475,
lon: 50.885,
embed:
"https://www.openstreetmap.org/export/embed.html?bbox=50.860%2C34.630%2C50.910%2C34.665&layer=mapnik&marker=34.6475%2C50.8850",
link: "https://www.openstreetmap.org/?mlat=34.6475&mlon=50.8850#map=15/34.6475/50.8850",
},
/* Logo -> link mapping taken from majdtrading.com's own markup. The names are
prose and live in the locale files, keyed by these ids — these bodies have
official names in all three languages. */
partners: [
{ id: "isipo", logo: "/images/customers/item1.png", url: "https://isipo.ir/" },
{ id: "mimt", logo: "/images/customers/item2.png", url: "https://dotic.ir/" },
{ id: "iccima", logo: "/images/customers/item3.png", url: "https://iccima.ir/" },
{ id: "qomccima", logo: "/images/customers/item4.png", url: "https://qomccima.ir/" },
{ id: "irica", logo: "/images/customers/item5.png", url: "https://irica.ir/" },
{ id: "iranfair", logo: "/images/customers/item6.png", url: "https://iranfair.com/" },
],
} as const;
export type PartnerId = (typeof site.partners)[number]["id"];
+24
View File
@@ -0,0 +1,24 @@
/**
* Meshkee canonical slug rules: keep Persian/Arabic letters, spaces become
* hyphens, other punctuation is stripped.
*
* Public detail URLs are `/{id}/{slug}` and the slug segment is SEO-only — the
* id resolves the page. The API's own `slug` field must NOT be used here: on
* real data it holds legacy values like `item-old-2945`.
*/
export function slugify(input: string): string {
return (
input
.trim()
.toLowerCase()
// Strip anything that is not a letter, digit, space or hyphen. Keeps
// Arabic-script letters (؀-ۿ) and Latin/other letters.
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "") || "post"
);
}
/** Canonical blog detail path, without the locale prefix. */
export const blogPath = (id: number, title: string) => `/blog/${id}/${slugify(title)}`;
+28
View File
@@ -0,0 +1,28 @@
/**
* Splits a short Latin heading into at most two lines, broken where the two
* halves come closest to equal length. Used for the faint backdrop behind
* Persian titles: a single long line runs the width of the section, while a
* balanced stack sits behind the heading lockup.
*
* Short strings ("ABOUT US") stay on one line — breaking them would strand a
* two-letter word on its own row.
*/
export function balanceLines(text: string, minLengthToBreak = 10): string[] {
const words = text.trim().split(/\s+/);
if (words.length < 2 || text.length <= minLengthToBreak) return [text];
let breakAt = 1;
let smallestDifference = Infinity;
for (let i = 1; i < words.length; i++) {
const head = words.slice(0, i).join(" ").length;
const tail = words.slice(i).join(" ").length;
const difference = Math.abs(head - tail);
if (difference < smallestDifference) {
smallestDifference = difference;
breakAt = i;
}
}
return [words.slice(0, breakAt).join(" "), words.slice(breakAt).join(" ")];
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse, type NextRequest } from "next/server";
import { locales, defaultLocale } from "@/i18n/config";
const PUBLIC_FILE = /\.(.*)$/;
/**
* Sends every un-prefixed path to a locale-prefixed one. Today `fa` is the only
* shipped locale; once `en` / `ar` are added, negotiate on `Accept-Language`
* here and the rest of the app needs no changes.
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasLocale = locales.some(
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)
);
if (hasLocale || PUBLIC_FILE.test(pathname)) return NextResponse.next();
const url = request.nextUrl.clone();
url.pathname = `/${defaultLocale}${pathname === "/" ? "" : pathname}`;
return NextResponse.redirect(url);
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|images|fonts|favicon.ico).*)"],
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}