Files
majd-trading/README.md
T
Alireza HassaniandClaude Opus 5 02aaf61bb6 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>
2026-08-29 19:16:32 +03:30

527 lines
25 KiB
Markdown

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