Majd Trading — ARIO MAJD ASIA

Next.js site for گروه بازرگانی بین المللی آریو مجد آسیا, rebuilt from 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

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

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 h2s, 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.

S
Description
No description provided
Readme
18 MiB
Languages
TypeScript 91.2%
CSS 7.1%
JavaScript 1.7%