# 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 and 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/.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 `` 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 `` 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 `

`** — 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.